Skip to content

Commit

Permalink
add getSuggestedFollowsByActor (#1553)
Browse files Browse the repository at this point in the history
* add getSuggestedFollowsByActor lex

* remove pagination

* codegen

* add pds route

* add app view route

* first pass at likes-based suggested actors, plus tests

* format

* backfill with suggested_follow table

* combine actors queries

* fall back to popular follows, handle backfill differently

* revert seed change, update test

* lower likes threshold

* cleanup

* remove todo

* format

* optimize queries

* cover mute lists

* clean up into pipeline steps

* add changeset
  • Loading branch information
estrattonbailey authored Sep 13, 2023
1 parent abc6cf9 commit 3877210
Show file tree
Hide file tree
Showing 17 changed files with 661 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .changeset/seven-schools-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@atproto/api': patch
---

Adds a new method `app.bsky.graph.getSuggestedFollowsByActor`. This method
returns suggested follows for a given actor based on their likes and follows.
33 changes: 33 additions & 0 deletions lexicons/app/bsky/graph/getSuggestedFollowsByActor.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"lexicon": 1,
"id": "app.bsky.graph.getSuggestedFollowsByActor",
"defs": {
"main": {
"type": "query",
"description": "Get suggested follows related to a given actor.",
"parameters": {
"type": "params",
"required": ["actor"],
"properties": {
"actor": { "type": "string", "format": "at-identifier" }
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["suggestions"],
"properties": {
"suggestions": {
"type": "array",
"items": {
"type": "ref",
"ref": "app.bsky.actor.defs#profileView"
}
}
}
}
}
}
}
}
18 changes: 18 additions & 0 deletions packages/api/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import * as AppBskyGraphGetListBlocks from './types/app/bsky/graph/getListBlocks
import * as AppBskyGraphGetListMutes from './types/app/bsky/graph/getListMutes'
import * as AppBskyGraphGetLists from './types/app/bsky/graph/getLists'
import * as AppBskyGraphGetMutes from './types/app/bsky/graph/getMutes'
import * as AppBskyGraphGetSuggestedFollowsByActor from './types/app/bsky/graph/getSuggestedFollowsByActor'
import * as AppBskyGraphList from './types/app/bsky/graph/list'
import * as AppBskyGraphListblock from './types/app/bsky/graph/listblock'
import * as AppBskyGraphListitem from './types/app/bsky/graph/listitem'
Expand Down Expand Up @@ -236,6 +237,7 @@ export * as AppBskyGraphGetListBlocks from './types/app/bsky/graph/getListBlocks
export * as AppBskyGraphGetListMutes from './types/app/bsky/graph/getListMutes'
export * as AppBskyGraphGetLists from './types/app/bsky/graph/getLists'
export * as AppBskyGraphGetMutes from './types/app/bsky/graph/getMutes'
export * as AppBskyGraphGetSuggestedFollowsByActor from './types/app/bsky/graph/getSuggestedFollowsByActor'
export * as AppBskyGraphList from './types/app/bsky/graph/list'
export * as AppBskyGraphListblock from './types/app/bsky/graph/listblock'
export * as AppBskyGraphListitem from './types/app/bsky/graph/listitem'
Expand Down Expand Up @@ -1712,6 +1714,22 @@ export class GraphNS {
})
}

getSuggestedFollowsByActor(
params?: AppBskyGraphGetSuggestedFollowsByActor.QueryParams,
opts?: AppBskyGraphGetSuggestedFollowsByActor.CallOptions,
): Promise<AppBskyGraphGetSuggestedFollowsByActor.Response> {
return this._service.xrpc
.call(
'app.bsky.graph.getSuggestedFollowsByActor',
params,
undefined,
opts,
)
.catch((e) => {
throw AppBskyGraphGetSuggestedFollowsByActor.toKnownErr(e)
})
}

muteActor(
data?: AppBskyGraphMuteActor.InputSchema,
opts?: AppBskyGraphMuteActor.CallOptions,
Expand Down
38 changes: 38 additions & 0 deletions packages/api/src/client/lexicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6109,6 +6109,42 @@ export const schemaDict = {
},
},
},
AppBskyGraphGetSuggestedFollowsByActor: {
lexicon: 1,
id: 'app.bsky.graph.getSuggestedFollowsByActor',
defs: {
main: {
type: 'query',
description: 'Get suggested follows related to a given actor.',
parameters: {
type: 'params',
required: ['actor'],
properties: {
actor: {
type: 'string',
format: 'at-identifier',
},
},
},
output: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['suggestions'],
properties: {
suggestions: {
type: 'array',
items: {
type: 'ref',
ref: 'lex:app.bsky.actor.defs#profileView',
},
},
},
},
},
},
},
},
AppBskyGraphList: {
lexicon: 1,
id: 'app.bsky.graph.list',
Expand Down Expand Up @@ -6845,6 +6881,8 @@ export const ids = {
AppBskyGraphGetListMutes: 'app.bsky.graph.getListMutes',
AppBskyGraphGetLists: 'app.bsky.graph.getLists',
AppBskyGraphGetMutes: 'app.bsky.graph.getMutes',
AppBskyGraphGetSuggestedFollowsByActor:
'app.bsky.graph.getSuggestedFollowsByActor',
AppBskyGraphList: 'app.bsky.graph.list',
AppBskyGraphListblock: 'app.bsky.graph.listblock',
AppBskyGraphListitem: 'app.bsky.graph.listitem',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import { Headers, XRPCError } from '@atproto/xrpc'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { isObj, hasProp } from '../../../../util'
import { lexicons } from '../../../../lexicons'
import { CID } from 'multiformats/cid'
import * as AppBskyActorDefs from '../actor/defs'

export interface QueryParams {
actor: string
}

export type InputSchema = undefined

export interface OutputSchema {
suggestions: AppBskyActorDefs.ProfileView[]
[k: string]: unknown
}

export interface CallOptions {
headers?: Headers
}

export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}

export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}
138 changes: 138 additions & 0 deletions packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { sql } from 'kysely'
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { InvalidRequestError } from '@atproto/xrpc-server'
import { Database } from '../../../../db'
import { ActorService } from '../../../../services/actor'

const RESULT_LENGTH = 10

export default function (server: Server, ctx: AppContext) {
server.app.bsky.graph.getSuggestedFollowsByActor({
auth: ctx.authVerifier,
handler: async ({ auth, params }) => {
const { actor } = params
const viewer = auth.credentials.did

const db = ctx.db.getReplica()
const actorService = ctx.services.actor(db)
const actorDid = await actorService.getActorDid(actor)

if (!actorDid) {
throw new InvalidRequestError('Actor not found')
}

const skeleton = await getSkeleton(
{
actor: actorDid,
viewer,
},
{
db,
actorService,
},
)
const hydrationState = await actorService.views.profileDetailHydration(
skeleton.map((a) => a.did),
{ viewer },
)
const presentationState = actorService.views.profileDetailPresentation(
skeleton.map((a) => a.did),
hydrationState,
{ viewer },
)
const suggestions = Object.values(presentationState).filter((profile) => {
return (
!profile.viewer?.muted &&
!profile.viewer?.mutedByList &&
!profile.viewer?.blocking &&
!profile.viewer?.blockedBy
)
})

return {
encoding: 'application/json',
body: { suggestions },
}
},
})
}

async function getSkeleton(
params: {
actor: string
viewer: string
},
ctx: {
db: Database
actorService: ActorService
},
): Promise<{ did: string }[]> {
const actorsViewerFollows = ctx.db.db
.selectFrom('follow')
.where('creator', '=', params.viewer)
.select('subjectDid')
const mostLikedAccounts = await ctx.db.db
.selectFrom(
ctx.db.db
.selectFrom('like')
.where('creator', '=', params.actor)
.select(sql`split_part(subject, '/', 3)`.as('subjectDid'))
.limit(1000) // limit to 1000
.as('likes'),
)
.select('likes.subjectDid as did')
.select((qb) => qb.fn.count('likes.subjectDid').as('count'))
.where('likes.subjectDid', 'not in', actorsViewerFollows)
.where('likes.subjectDid', 'not in', [params.actor, params.viewer])
.groupBy('likes.subjectDid')
.orderBy('count', 'desc')
.limit(RESULT_LENGTH)
.execute()
const resultDids = mostLikedAccounts.map((a) => ({ did: a.did })) as {
did: string
}[]

if (resultDids.length < RESULT_LENGTH) {
// backfill with popular accounts followed by actor
const mostPopularAccountsActorFollows = await ctx.db.db
.selectFrom('follow')
.innerJoin('profile_agg', 'follow.subjectDid', 'profile_agg.did')
.select('follow.subjectDid as did')
.where('follow.creator', '=', params.actor)
.where('follow.subjectDid', '!=', params.viewer)
.where('follow.subjectDid', 'not in', actorsViewerFollows)
.if(resultDids.length > 0, (qb) =>
qb.where(
'subjectDid',
'not in',
resultDids.map((a) => a.did),
),
)
.orderBy('profile_agg.followersCount', 'desc')
.limit(RESULT_LENGTH)
.execute()

resultDids.push(...mostPopularAccountsActorFollows)
}

if (resultDids.length < RESULT_LENGTH) {
// backfill with suggested_follow table
const additional = await ctx.db.db
.selectFrom('suggested_follow')
.where(
'did',
'not in',
// exclude any we already have
resultDids.map((a) => a.did).concat([params.actor, params.viewer]),
)
// and aren't already followed by viewer
.where('did', 'not in', actorsViewerFollows)
.selectAll()
.execute()

resultDids.push(...additional)
}

return resultDids
}
2 changes: 2 additions & 0 deletions packages/bsky/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import muteActor from './app/bsky/graph/muteActor'
import unmuteActor from './app/bsky/graph/unmuteActor'
import muteActorList from './app/bsky/graph/muteActorList'
import unmuteActorList from './app/bsky/graph/unmuteActorList'
import getSuggestedFollowsByActor from './app/bsky/graph/getSuggestedFollowsByActor'
import searchActors from './app/bsky/actor/searchActors'
import searchActorsTypeahead from './app/bsky/actor/searchActorsTypeahead'
import getSuggestions from './app/bsky/actor/getSuggestions'
Expand Down Expand Up @@ -87,6 +88,7 @@ export default function (server: Server, ctx: AppContext) {
unmuteActor(server, ctx)
muteActorList(server, ctx)
unmuteActorList(server, ctx)
getSuggestedFollowsByActor(server, ctx)
searchActors(server, ctx)
searchActorsTypeahead(server, ctx)
getSuggestions(server, ctx)
Expand Down
12 changes: 12 additions & 0 deletions packages/bsky/src/lexicon/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import * as AppBskyGraphGetListBlocks from './types/app/bsky/graph/getListBlocks
import * as AppBskyGraphGetListMutes from './types/app/bsky/graph/getListMutes'
import * as AppBskyGraphGetLists from './types/app/bsky/graph/getLists'
import * as AppBskyGraphGetMutes from './types/app/bsky/graph/getMutes'
import * as AppBskyGraphGetSuggestedFollowsByActor from './types/app/bsky/graph/getSuggestedFollowsByActor'
import * as AppBskyGraphMuteActor from './types/app/bsky/graph/muteActor'
import * as AppBskyGraphMuteActorList from './types/app/bsky/graph/muteActorList'
import * as AppBskyGraphUnmuteActor from './types/app/bsky/graph/unmuteActor'
Expand Down Expand Up @@ -1251,6 +1252,17 @@ export class GraphNS {
return this._server.xrpc.method(nsid, cfg)
}

getSuggestedFollowsByActor<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,
AppBskyGraphGetSuggestedFollowsByActor.Handler<ExtractAuth<AV>>,
AppBskyGraphGetSuggestedFollowsByActor.HandlerReqCtx<ExtractAuth<AV>>
>,
) {
const nsid = 'app.bsky.graph.getSuggestedFollowsByActor' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}

muteActor<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,
Expand Down
Loading

0 comments on commit 3877210

Please sign in to comment.