Skip to content

Commit 3877210

Browse files
add getSuggestedFollowsByActor (#1553)
* 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
1 parent abc6cf9 commit 3877210

File tree

17 files changed

+661
-0
lines changed

17 files changed

+661
-0
lines changed

.changeset/seven-schools-switch.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@atproto/api': patch
3+
---
4+
5+
Adds a new method `app.bsky.graph.getSuggestedFollowsByActor`. This method
6+
returns suggested follows for a given actor based on their likes and follows.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"lexicon": 1,
3+
"id": "app.bsky.graph.getSuggestedFollowsByActor",
4+
"defs": {
5+
"main": {
6+
"type": "query",
7+
"description": "Get suggested follows related to a given actor.",
8+
"parameters": {
9+
"type": "params",
10+
"required": ["actor"],
11+
"properties": {
12+
"actor": { "type": "string", "format": "at-identifier" }
13+
}
14+
},
15+
"output": {
16+
"encoding": "application/json",
17+
"schema": {
18+
"type": "object",
19+
"required": ["suggestions"],
20+
"properties": {
21+
"suggestions": {
22+
"type": "array",
23+
"items": {
24+
"type": "ref",
25+
"ref": "app.bsky.actor.defs#profileView"
26+
}
27+
}
28+
}
29+
}
30+
}
31+
}
32+
}
33+
}

packages/api/src/client/index.ts

+18
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ import * as AppBskyGraphGetListBlocks from './types/app/bsky/graph/getListBlocks
113113
import * as AppBskyGraphGetListMutes from './types/app/bsky/graph/getListMutes'
114114
import * as AppBskyGraphGetLists from './types/app/bsky/graph/getLists'
115115
import * as AppBskyGraphGetMutes from './types/app/bsky/graph/getMutes'
116+
import * as AppBskyGraphGetSuggestedFollowsByActor from './types/app/bsky/graph/getSuggestedFollowsByActor'
116117
import * as AppBskyGraphList from './types/app/bsky/graph/list'
117118
import * as AppBskyGraphListblock from './types/app/bsky/graph/listblock'
118119
import * as AppBskyGraphListitem from './types/app/bsky/graph/listitem'
@@ -236,6 +237,7 @@ export * as AppBskyGraphGetListBlocks from './types/app/bsky/graph/getListBlocks
236237
export * as AppBskyGraphGetListMutes from './types/app/bsky/graph/getListMutes'
237238
export * as AppBskyGraphGetLists from './types/app/bsky/graph/getLists'
238239
export * as AppBskyGraphGetMutes from './types/app/bsky/graph/getMutes'
240+
export * as AppBskyGraphGetSuggestedFollowsByActor from './types/app/bsky/graph/getSuggestedFollowsByActor'
239241
export * as AppBskyGraphList from './types/app/bsky/graph/list'
240242
export * as AppBskyGraphListblock from './types/app/bsky/graph/listblock'
241243
export * as AppBskyGraphListitem from './types/app/bsky/graph/listitem'
@@ -1712,6 +1714,22 @@ export class GraphNS {
17121714
})
17131715
}
17141716

1717+
getSuggestedFollowsByActor(
1718+
params?: AppBskyGraphGetSuggestedFollowsByActor.QueryParams,
1719+
opts?: AppBskyGraphGetSuggestedFollowsByActor.CallOptions,
1720+
): Promise<AppBskyGraphGetSuggestedFollowsByActor.Response> {
1721+
return this._service.xrpc
1722+
.call(
1723+
'app.bsky.graph.getSuggestedFollowsByActor',
1724+
params,
1725+
undefined,
1726+
opts,
1727+
)
1728+
.catch((e) => {
1729+
throw AppBskyGraphGetSuggestedFollowsByActor.toKnownErr(e)
1730+
})
1731+
}
1732+
17151733
muteActor(
17161734
data?: AppBskyGraphMuteActor.InputSchema,
17171735
opts?: AppBskyGraphMuteActor.CallOptions,

packages/api/src/client/lexicons.ts

+38
Original file line numberDiff line numberDiff line change
@@ -6109,6 +6109,42 @@ export const schemaDict = {
61096109
},
61106110
},
61116111
},
6112+
AppBskyGraphGetSuggestedFollowsByActor: {
6113+
lexicon: 1,
6114+
id: 'app.bsky.graph.getSuggestedFollowsByActor',
6115+
defs: {
6116+
main: {
6117+
type: 'query',
6118+
description: 'Get suggested follows related to a given actor.',
6119+
parameters: {
6120+
type: 'params',
6121+
required: ['actor'],
6122+
properties: {
6123+
actor: {
6124+
type: 'string',
6125+
format: 'at-identifier',
6126+
},
6127+
},
6128+
},
6129+
output: {
6130+
encoding: 'application/json',
6131+
schema: {
6132+
type: 'object',
6133+
required: ['suggestions'],
6134+
properties: {
6135+
suggestions: {
6136+
type: 'array',
6137+
items: {
6138+
type: 'ref',
6139+
ref: 'lex:app.bsky.actor.defs#profileView',
6140+
},
6141+
},
6142+
},
6143+
},
6144+
},
6145+
},
6146+
},
6147+
},
61126148
AppBskyGraphList: {
61136149
lexicon: 1,
61146150
id: 'app.bsky.graph.list',
@@ -6845,6 +6881,8 @@ export const ids = {
68456881
AppBskyGraphGetListMutes: 'app.bsky.graph.getListMutes',
68466882
AppBskyGraphGetLists: 'app.bsky.graph.getLists',
68476883
AppBskyGraphGetMutes: 'app.bsky.graph.getMutes',
6884+
AppBskyGraphGetSuggestedFollowsByActor:
6885+
'app.bsky.graph.getSuggestedFollowsByActor',
68486886
AppBskyGraphList: 'app.bsky.graph.list',
68496887
AppBskyGraphListblock: 'app.bsky.graph.listblock',
68506888
AppBskyGraphListitem: 'app.bsky.graph.listitem',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* GENERATED CODE - DO NOT MODIFY
3+
*/
4+
import { Headers, XRPCError } from '@atproto/xrpc'
5+
import { ValidationResult, BlobRef } from '@atproto/lexicon'
6+
import { isObj, hasProp } from '../../../../util'
7+
import { lexicons } from '../../../../lexicons'
8+
import { CID } from 'multiformats/cid'
9+
import * as AppBskyActorDefs from '../actor/defs'
10+
11+
export interface QueryParams {
12+
actor: string
13+
}
14+
15+
export type InputSchema = undefined
16+
17+
export interface OutputSchema {
18+
suggestions: AppBskyActorDefs.ProfileView[]
19+
[k: string]: unknown
20+
}
21+
22+
export interface CallOptions {
23+
headers?: Headers
24+
}
25+
26+
export interface Response {
27+
success: boolean
28+
headers: Headers
29+
data: OutputSchema
30+
}
31+
32+
export function toKnownErr(e: any) {
33+
if (e instanceof XRPCError) {
34+
}
35+
return e
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { sql } from 'kysely'
2+
import { Server } from '../../../../lexicon'
3+
import AppContext from '../../../../context'
4+
import { InvalidRequestError } from '@atproto/xrpc-server'
5+
import { Database } from '../../../../db'
6+
import { ActorService } from '../../../../services/actor'
7+
8+
const RESULT_LENGTH = 10
9+
10+
export default function (server: Server, ctx: AppContext) {
11+
server.app.bsky.graph.getSuggestedFollowsByActor({
12+
auth: ctx.authVerifier,
13+
handler: async ({ auth, params }) => {
14+
const { actor } = params
15+
const viewer = auth.credentials.did
16+
17+
const db = ctx.db.getReplica()
18+
const actorService = ctx.services.actor(db)
19+
const actorDid = await actorService.getActorDid(actor)
20+
21+
if (!actorDid) {
22+
throw new InvalidRequestError('Actor not found')
23+
}
24+
25+
const skeleton = await getSkeleton(
26+
{
27+
actor: actorDid,
28+
viewer,
29+
},
30+
{
31+
db,
32+
actorService,
33+
},
34+
)
35+
const hydrationState = await actorService.views.profileDetailHydration(
36+
skeleton.map((a) => a.did),
37+
{ viewer },
38+
)
39+
const presentationState = actorService.views.profileDetailPresentation(
40+
skeleton.map((a) => a.did),
41+
hydrationState,
42+
{ viewer },
43+
)
44+
const suggestions = Object.values(presentationState).filter((profile) => {
45+
return (
46+
!profile.viewer?.muted &&
47+
!profile.viewer?.mutedByList &&
48+
!profile.viewer?.blocking &&
49+
!profile.viewer?.blockedBy
50+
)
51+
})
52+
53+
return {
54+
encoding: 'application/json',
55+
body: { suggestions },
56+
}
57+
},
58+
})
59+
}
60+
61+
async function getSkeleton(
62+
params: {
63+
actor: string
64+
viewer: string
65+
},
66+
ctx: {
67+
db: Database
68+
actorService: ActorService
69+
},
70+
): Promise<{ did: string }[]> {
71+
const actorsViewerFollows = ctx.db.db
72+
.selectFrom('follow')
73+
.where('creator', '=', params.viewer)
74+
.select('subjectDid')
75+
const mostLikedAccounts = await ctx.db.db
76+
.selectFrom(
77+
ctx.db.db
78+
.selectFrom('like')
79+
.where('creator', '=', params.actor)
80+
.select(sql`split_part(subject, '/', 3)`.as('subjectDid'))
81+
.limit(1000) // limit to 1000
82+
.as('likes'),
83+
)
84+
.select('likes.subjectDid as did')
85+
.select((qb) => qb.fn.count('likes.subjectDid').as('count'))
86+
.where('likes.subjectDid', 'not in', actorsViewerFollows)
87+
.where('likes.subjectDid', 'not in', [params.actor, params.viewer])
88+
.groupBy('likes.subjectDid')
89+
.orderBy('count', 'desc')
90+
.limit(RESULT_LENGTH)
91+
.execute()
92+
const resultDids = mostLikedAccounts.map((a) => ({ did: a.did })) as {
93+
did: string
94+
}[]
95+
96+
if (resultDids.length < RESULT_LENGTH) {
97+
// backfill with popular accounts followed by actor
98+
const mostPopularAccountsActorFollows = await ctx.db.db
99+
.selectFrom('follow')
100+
.innerJoin('profile_agg', 'follow.subjectDid', 'profile_agg.did')
101+
.select('follow.subjectDid as did')
102+
.where('follow.creator', '=', params.actor)
103+
.where('follow.subjectDid', '!=', params.viewer)
104+
.where('follow.subjectDid', 'not in', actorsViewerFollows)
105+
.if(resultDids.length > 0, (qb) =>
106+
qb.where(
107+
'subjectDid',
108+
'not in',
109+
resultDids.map((a) => a.did),
110+
),
111+
)
112+
.orderBy('profile_agg.followersCount', 'desc')
113+
.limit(RESULT_LENGTH)
114+
.execute()
115+
116+
resultDids.push(...mostPopularAccountsActorFollows)
117+
}
118+
119+
if (resultDids.length < RESULT_LENGTH) {
120+
// backfill with suggested_follow table
121+
const additional = await ctx.db.db
122+
.selectFrom('suggested_follow')
123+
.where(
124+
'did',
125+
'not in',
126+
// exclude any we already have
127+
resultDids.map((a) => a.did).concat([params.actor, params.viewer]),
128+
)
129+
// and aren't already followed by viewer
130+
.where('did', 'not in', actorsViewerFollows)
131+
.selectAll()
132+
.execute()
133+
134+
resultDids.push(...additional)
135+
}
136+
137+
return resultDids
138+
}

packages/bsky/src/api/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import muteActor from './app/bsky/graph/muteActor'
2828
import unmuteActor from './app/bsky/graph/unmuteActor'
2929
import muteActorList from './app/bsky/graph/muteActorList'
3030
import unmuteActorList from './app/bsky/graph/unmuteActorList'
31+
import getSuggestedFollowsByActor from './app/bsky/graph/getSuggestedFollowsByActor'
3132
import searchActors from './app/bsky/actor/searchActors'
3233
import searchActorsTypeahead from './app/bsky/actor/searchActorsTypeahead'
3334
import getSuggestions from './app/bsky/actor/getSuggestions'
@@ -87,6 +88,7 @@ export default function (server: Server, ctx: AppContext) {
8788
unmuteActor(server, ctx)
8889
muteActorList(server, ctx)
8990
unmuteActorList(server, ctx)
91+
getSuggestedFollowsByActor(server, ctx)
9092
searchActors(server, ctx)
9193
searchActorsTypeahead(server, ctx)
9294
getSuggestions(server, ctx)

packages/bsky/src/lexicon/index.ts

+12
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ import * as AppBskyGraphGetListBlocks from './types/app/bsky/graph/getListBlocks
9696
import * as AppBskyGraphGetListMutes from './types/app/bsky/graph/getListMutes'
9797
import * as AppBskyGraphGetLists from './types/app/bsky/graph/getLists'
9898
import * as AppBskyGraphGetMutes from './types/app/bsky/graph/getMutes'
99+
import * as AppBskyGraphGetSuggestedFollowsByActor from './types/app/bsky/graph/getSuggestedFollowsByActor'
99100
import * as AppBskyGraphMuteActor from './types/app/bsky/graph/muteActor'
100101
import * as AppBskyGraphMuteActorList from './types/app/bsky/graph/muteActorList'
101102
import * as AppBskyGraphUnmuteActor from './types/app/bsky/graph/unmuteActor'
@@ -1251,6 +1252,17 @@ export class GraphNS {
12511252
return this._server.xrpc.method(nsid, cfg)
12521253
}
12531254

1255+
getSuggestedFollowsByActor<AV extends AuthVerifier>(
1256+
cfg: ConfigOf<
1257+
AV,
1258+
AppBskyGraphGetSuggestedFollowsByActor.Handler<ExtractAuth<AV>>,
1259+
AppBskyGraphGetSuggestedFollowsByActor.HandlerReqCtx<ExtractAuth<AV>>
1260+
>,
1261+
) {
1262+
const nsid = 'app.bsky.graph.getSuggestedFollowsByActor' // @ts-ignore
1263+
return this._server.xrpc.method(nsid, cfg)
1264+
}
1265+
12541266
muteActor<AV extends AuthVerifier>(
12551267
cfg: ConfigOf<
12561268
AV,

0 commit comments

Comments
 (0)