diff --git a/packages/bsky/src/api/app/bsky/actor/searchActors.ts b/packages/bsky/src/api/app/bsky/actor/searchActors.ts index d4ae0a8d264..4fcb29de089 100644 --- a/packages/bsky/src/api/app/bsky/actor/searchActors.ts +++ b/packages/bsky/src/api/app/bsky/actor/searchActors.ts @@ -2,7 +2,7 @@ import { sql } from 'kysely' import AppContext from '../../../../context' import { Server } from '../../../../lexicon' import { - cleanTerm, + cleanQuery, getUserSearchQuery, SearchKeyset, } from '../../../../services/util/search' @@ -11,37 +11,50 @@ export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.searchActors({ auth: ctx.authOptionalVerifier, handler: async ({ auth, params }) => { - let { cursor, limit, term: rawTerm, q: rawQ } = params + const { cursor, limit } = params const requester = auth.credentials.did - - // prefer new 'q' query param over deprecated 'term' - if (rawQ) { - rawTerm = rawQ - } - - const term = cleanTerm(rawTerm || '') - + const rawQuery = params.q ?? params.term + const query = cleanQuery(rawQuery || '') const db = ctx.db.getReplica('search') - const results = term - ? await getUserSearchQuery(db, { term, limit, cursor }) - .select('distance') - .selectAll('actor') - .execute() - : [] - const keyset = new SearchKeyset(sql``, sql``) + let results: string[] + let resCursor: string | undefined + if (ctx.searchAgent) { + const res = + await ctx.searchAgent.api.app.bsky.unspecced.searchActorsSkeleton({ + q: query, + cursor, + limit, + }) + results = res.data.actors.map((a) => a.did) + resCursor = res.data.cursor + } else { + const res = query + ? await getUserSearchQuery(db, { query, limit, cursor }) + .select('distance') + .selectAll('actor') + .execute() + : [] + results = res.map((a) => a.did) + const keyset = new SearchKeyset(sql``, sql``) + resCursor = keyset.packFromResult(res) + } const actors = await ctx.services .actor(db) - .views.profilesList(results, requester) - const filtered = actors.filter( - (actor) => !actor.viewer?.blocking && !actor.viewer?.blockedBy, - ) + .views.profiles(results, requester) + + const SKIP = [] + const filtered = results.flatMap((did) => { + const actor = actors[did] + if (actor.viewer?.blocking || actor.viewer?.blockedBy) return SKIP + return actor + }) return { encoding: 'application/json', body: { - cursor: keyset.packFromResult(results), + cursor: resCursor, actors: filtered, }, } diff --git a/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts b/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts index c438c4d2324..9c09a54ac7e 100644 --- a/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts +++ b/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts @@ -1,7 +1,7 @@ import AppContext from '../../../../context' import { Server } from '../../../../lexicon' import { - cleanTerm, + cleanQuery, getUserSearchQuerySimple, } from '../../../../services/util/search' @@ -9,31 +9,37 @@ export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.searchActorsTypeahead({ auth: ctx.authOptionalVerifier, handler: async ({ params, auth }) => { - let { limit, term: rawTerm, q: rawQ } = params + const { limit } = params const requester = auth.credentials.did - - // prefer new 'q' query param over deprecated 'term' - if (rawQ) { - rawTerm = rawQ - } - - const term = cleanTerm(rawTerm || '') - + const rawQuery = params.q ?? params.term + const query = cleanQuery(rawQuery || '') const db = ctx.db.getReplica('search') - const results = term - ? await getUserSearchQuerySimple(db, { term, limit }) - .selectAll('actor') - .execute() - : [] + let results: string[] + if (ctx.searchAgent) { + const res = + await ctx.searchAgent.api.app.bsky.unspecced.searchActorsSkeleton({ + q: query, + typeahead: true, + limit, + }) + results = res.data.actors.map((a) => a.did) + } else { + const res = query + ? await getUserSearchQuerySimple(db, { query, limit }) + .selectAll('actor') + .execute() + : [] + results = res.map((a) => a.did) + } const actors = await ctx.services .actor(db) .views.profilesBasic(results, requester, { omitLabels: true }) const SKIP = [] - const filtered = results.flatMap((res) => { - const actor = actors[res.did] + const filtered = results.flatMap((did) => { + const actor = actors[did] if (actor.viewer?.blocking || actor.viewer?.blockedBy) return SKIP return actor }) diff --git a/packages/bsky/src/api/com/atproto/admin/searchRepos.ts b/packages/bsky/src/api/com/atproto/admin/searchRepos.ts index a17421e90cd..8faf041f589 100644 --- a/packages/bsky/src/api/com/atproto/admin/searchRepos.ts +++ b/packages/bsky/src/api/com/atproto/admin/searchRepos.ts @@ -8,23 +8,20 @@ export default function (server: Server, ctx: AppContext) { handler: async ({ params }) => { const db = ctx.db.getPrimary() const moderationService = ctx.services.moderation(db) - const { invitedBy } = params + const { invitedBy, limit, cursor } = params if (invitedBy) { throw new InvalidRequestError('The invitedBy parameter is unsupported') } // prefer new 'q' query param over deprecated 'term' - const { q } = params - if (q) { - params.term = q - } + const query = params.q ?? params.term - const { results, cursor } = await ctx.services + const { results, cursor: resCursor } = await ctx.services .actor(db) - .getSearchResults({ ...params, includeSoftDeleted: true }) + .getSearchResults({ query, limit, cursor, includeSoftDeleted: true }) return { encoding: 'application/json', body: { - cursor, + cursor: resCursor, repos: await moderationService.views.repo(results), }, } diff --git a/packages/bsky/src/config.ts b/packages/bsky/src/config.ts index 2ef7e1edf6c..38d9c883760 100644 --- a/packages/bsky/src/config.ts +++ b/packages/bsky/src/config.ts @@ -18,6 +18,7 @@ export interface ServerConfigValues { handleResolveNameservers?: string[] imgUriEndpoint?: string blobCacheLocation?: string + searchEndpoint?: string labelerDid: string adminPassword: string moderatorPassword?: string @@ -51,6 +52,7 @@ export class ServerConfig { : [] const imgUriEndpoint = process.env.IMG_URI_ENDPOINT const blobCacheLocation = process.env.BLOB_CACHE_LOC + const searchEndpoint = process.env.SEARCH_ENDPOINT const dbPrimaryPostgresUrl = overrides?.dbPrimaryPostgresUrl || process.env.DB_PRIMARY_POSTGRES_URL let dbReplicaPostgresUrls = overrides?.dbReplicaPostgresUrls @@ -97,6 +99,7 @@ export class ServerConfig { handleResolveNameservers, imgUriEndpoint, blobCacheLocation, + searchEndpoint, labelerDid, adminPassword, moderatorPassword, @@ -183,6 +186,10 @@ export class ServerConfig { return this.cfg.blobCacheLocation } + get searchEndpoint() { + return this.cfg.searchEndpoint + } + get labelerDid() { return this.cfg.labelerDid } diff --git a/packages/bsky/src/context.ts b/packages/bsky/src/context.ts index 343d105ce1a..42cbfecf218 100644 --- a/packages/bsky/src/context.ts +++ b/packages/bsky/src/context.ts @@ -10,6 +10,7 @@ import { BackgroundQueue } from './background' import { MountedAlgos } from './feed-gen/types' import { LabelCache } from './label-cache' import { NotificationServer } from './notifications' +import { AtpAgent } from '@atproto/api' export class AppContext { constructor( @@ -22,6 +23,7 @@ export class AppContext { didCache: DidSqlCache labelCache: LabelCache backgroundQueue: BackgroundQueue + searchAgent?: AtpAgent algos: MountedAlgos notifServer: NotificationServer }, @@ -63,6 +65,10 @@ export class AppContext { return this.opts.notifServer } + get searchAgent(): AtpAgent | undefined { + return this.opts.searchAgent + } + get authVerifier() { return auth.authVerifier(this.idResolver, { aud: this.cfg.serverDid }) } diff --git a/packages/bsky/src/index.ts b/packages/bsky/src/index.ts index b469c2f7b17..8ef2109218e 100644 --- a/packages/bsky/src/index.ts +++ b/packages/bsky/src/index.ts @@ -25,6 +25,7 @@ import { BackgroundQueue } from './background' import { MountedAlgos } from './feed-gen/types' import { LabelCache } from './label-cache' import { NotificationServer } from './notifications' +import { AtpAgent } from '@atproto/api' export type { ServerConfigValues } from './config' export type { MountedAlgos } from './feed-gen/types' @@ -100,6 +101,9 @@ export class BskyAppView { const backgroundQueue = new BackgroundQueue(db.getPrimary()) const labelCache = new LabelCache(db.getPrimary()) const notifServer = new NotificationServer(db.getPrimary()) + const searchAgent = config.searchEndpoint + ? new AtpAgent({ service: config.searchEndpoint }) + : undefined const services = createServices({ imgUriBuilder, @@ -116,6 +120,7 @@ export class BskyAppView { didCache, labelCache, backgroundQueue, + searchAgent, algos, notifServer, }) diff --git a/packages/bsky/src/services/actor/index.ts b/packages/bsky/src/services/actor/index.ts index 54bfb714146..0f90e550f3c 100644 --- a/packages/bsky/src/services/actor/index.ts +++ b/packages/bsky/src/services/actor/index.ts @@ -83,15 +83,15 @@ export class ActorService { async getSearchResults({ cursor, limit = 25, - term = '', + query = '', includeSoftDeleted, }: { cursor?: string limit?: number - term?: string + query?: string includeSoftDeleted?: boolean }) { - const searchField = term.startsWith('did:') ? 'did' : 'handle' + const searchField = query.startsWith('did:') ? 'did' : 'handle' let paginatedBuilder const { ref } = this.db.db.dynamic const paginationOptions = { @@ -101,10 +101,10 @@ export class ActorService { } let keyset - if (term && searchField === 'handle') { + if (query && searchField === 'handle') { keyset = new SearchKeyset(sql``, sql``) paginatedBuilder = getUserSearchQuery(this.db, { - term, + query, includeSoftDeleted, ...paginationOptions, }).select('distance') @@ -114,10 +114,10 @@ export class ActorService { .select([sql`0`.as('distance')]) keyset = new ListKeyset(ref('indexedAt'), ref('did')) - // When searchField === 'did', the term will always be a valid string because - // searchField is set to 'did' after checking that the term is a valid did - if (term && searchField === 'did') { - paginatedBuilder = paginatedBuilder.where('actor.did', '=', term) + // When searchField === 'did', the query will always be a valid string because + // searchField is set to 'did' after checking that the query is a valid did + if (query && searchField === 'did') { + paginatedBuilder = paginatedBuilder.where('actor.did', '=', query) } paginatedBuilder = paginate(paginatedBuilder, { keyset, diff --git a/packages/bsky/src/services/util/search.ts b/packages/bsky/src/services/util/search.ts index 9dfebadb613..994d2f43879 100644 --- a/packages/bsky/src/services/util/search.ts +++ b/packages/bsky/src/services/util/search.ts @@ -7,17 +7,17 @@ import { GenericKeyset, paginate } from '../../db/pagination' export const getUserSearchQuery = ( db: Database, opts: { - term: string + query: string limit: number cursor?: string includeSoftDeleted?: boolean }, ) => { const { ref } = db.db.dynamic - const { term, limit, cursor, includeSoftDeleted } = opts + const { query, limit, cursor, includeSoftDeleted } = opts // Matching user accounts based on handle - const distanceAccount = distance(term, ref('handle')) - let accountsQb = getMatchingAccountsQb(db, { term, includeSoftDeleted }) + const distanceAccount = distance(query, ref('handle')) + let accountsQb = getMatchingAccountsQb(db, { query, includeSoftDeleted }) accountsQb = paginate(accountsQb, { limit, cursor, @@ -25,8 +25,8 @@ export const getUserSearchQuery = ( keyset: new SearchKeyset(distanceAccount, ref('actor.did')), }) // Matching profiles based on display name - const distanceProfile = distance(term, ref('displayName')) - let profilesQb = getMatchingProfilesQb(db, { term, includeSoftDeleted }) + const distanceProfile = distance(query, ref('displayName')) + let profilesQb = getMatchingProfilesQb(db, { query, includeSoftDeleted }) profilesQb = paginate(profilesQb, { limit, cursor, @@ -46,18 +46,18 @@ export const getUserSearchQuery = ( export const getUserSearchQuerySimple = ( db: Database, opts: { - term: string + query: string limit: number }, ) => { const { ref } = db.db.dynamic - const { term, limit } = opts + const { query, limit } = opts // Matching user accounts based on handle - const accountsQb = getMatchingAccountsQb(db, { term }) + const accountsQb = getMatchingAccountsQb(db, { query }) .orderBy('distance', 'asc') .limit(limit) // Matching profiles based on display name - const profilesQb = getMatchingProfilesQb(db, { term }) + const profilesQb = getMatchingProfilesQb(db, { query }) .orderBy('distance', 'asc') .limit(limit) // Combine and paginate result set @@ -71,29 +71,29 @@ export const getUserSearchQuerySimple = ( // Matching user accounts based on handle const getMatchingAccountsQb = ( db: Database, - opts: { term: string; includeSoftDeleted?: boolean }, + opts: { query: string; includeSoftDeleted?: boolean }, ) => { const { ref } = db.db.dynamic - const { term, includeSoftDeleted } = opts - const distanceAccount = distance(term, ref('handle')) + const { query, includeSoftDeleted } = opts + const distanceAccount = distance(query, ref('handle')) return db.db .selectFrom('actor') .if(!includeSoftDeleted, (qb) => qb.where(notSoftDeletedClause(ref('actor'))), ) .where('actor.handle', 'is not', null) - .where(similar(term, ref('handle'))) // Coarse filter engaging trigram index + .where(similar(query, ref('handle'))) // Coarse filter engaging trigram index .select(['actor.did as did', distanceAccount.as('distance')]) } // Matching profiles based on display name const getMatchingProfilesQb = ( db: Database, - opts: { term: string; includeSoftDeleted?: boolean }, + opts: { query: string; includeSoftDeleted?: boolean }, ) => { const { ref } = db.db.dynamic - const { term, includeSoftDeleted } = opts - const distanceProfile = distance(term, ref('displayName')) + const { query, includeSoftDeleted } = opts + const distanceProfile = distance(query, ref('displayName')) return db.db .selectFrom('profile') .innerJoin('actor', 'actor.did', 'profile.creator') @@ -101,7 +101,7 @@ const getMatchingProfilesQb = ( qb.where(notSoftDeletedClause(ref('actor'))), ) .where('actor.handle', 'is not', null) - .where(similar(term, ref('displayName'))) // Coarse filter engaging trigram index + .where(similar(query, ref('displayName'))) // Coarse filter engaging trigram index .select(['profile.creator as did', distanceProfile.as('distance')]) } @@ -133,15 +133,16 @@ const combineAccountsAndProfilesQb = ( } // Remove leading @ in case a handle is input that way -export const cleanTerm = (term: string) => term.trim().replace(/^@/g, '') +export const cleanQuery = (query: string) => query.trim().replace(/^@/g, '') -// Uses pg_trgm strict word similarity to check similarity between a search term and a stored value -const distance = (term: string, ref: DbRef) => - sql`(${term} <<-> ${ref})` +// Uses pg_trgm strict word similarity to check similarity between a search query and a stored value +const distance = (query: string, ref: DbRef) => + sql`(${query} <<-> ${ref})` // Can utilize trigram index to match on strict word similarity. // The word_similarity_threshold is set to .4 (i.e. distance < .6) in db/index.ts. -const similar = (term: string, ref: DbRef) => sql`(${term} <% ${ref})` +const similar = (query: string, ref: DbRef) => + sql`(${query} <% ${ref})` type Result = { distance: number; did: string } type LabeledResult = { primary: number; secondary: string }