From 3877210e7fb3c76dfb1a11eb9ba3f18426301d9f Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 13 Sep 2023 15:56:12 -0500 Subject: [PATCH] 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 --- .changeset/seven-schools-switch.md | 6 + .../graph/getSuggestedFollowsByActor.json | 33 ++++ packages/api/src/client/index.ts | 18 +++ packages/api/src/client/lexicons.ts | 38 +++++ .../bsky/graph/getSuggestedFollowsByActor.ts | 36 +++++ .../bsky/graph/getSuggestedFollowsByActor.ts | 138 ++++++++++++++++ packages/bsky/src/api/index.ts | 2 + packages/bsky/src/lexicon/index.ts | 12 ++ packages/bsky/src/lexicon/lexicons.ts | 38 +++++ .../bsky/graph/getSuggestedFollowsByActor.ts | 46 ++++++ packages/bsky/tests/seeds/likes.ts | 29 ++++ .../tests/views/suggested-follows.test.ts | 147 ++++++++++++++++++ .../bsky/graph/getSuggestedFollowsByActor.ts | 20 +++ .../pds/src/app-view/api/app/bsky/index.ts | 2 + packages/pds/src/lexicon/index.ts | 12 ++ packages/pds/src/lexicon/lexicons.ts | 38 +++++ .../bsky/graph/getSuggestedFollowsByActor.ts | 46 ++++++ 17 files changed, 661 insertions(+) create mode 100644 .changeset/seven-schools-switch.md create mode 100644 lexicons/app/bsky/graph/getSuggestedFollowsByActor.json create mode 100644 packages/api/src/client/types/app/bsky/graph/getSuggestedFollowsByActor.ts create mode 100644 packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts create mode 100644 packages/bsky/src/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.ts create mode 100644 packages/bsky/tests/views/suggested-follows.test.ts create mode 100644 packages/pds/src/app-view/api/app/bsky/graph/getSuggestedFollowsByActor.ts create mode 100644 packages/pds/src/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.ts diff --git a/.changeset/seven-schools-switch.md b/.changeset/seven-schools-switch.md new file mode 100644 index 00000000000..012cf392426 --- /dev/null +++ b/.changeset/seven-schools-switch.md @@ -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. diff --git a/lexicons/app/bsky/graph/getSuggestedFollowsByActor.json b/lexicons/app/bsky/graph/getSuggestedFollowsByActor.json new file mode 100644 index 00000000000..32873a537c9 --- /dev/null +++ b/lexicons/app/bsky/graph/getSuggestedFollowsByActor.json @@ -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" + } + } + } + } + } + } + } +} diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index 16728348374..761097aad7c 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -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' @@ -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' @@ -1712,6 +1714,22 @@ export class GraphNS { }) } + getSuggestedFollowsByActor( + params?: AppBskyGraphGetSuggestedFollowsByActor.QueryParams, + opts?: AppBskyGraphGetSuggestedFollowsByActor.CallOptions, + ): Promise { + 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, diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 30da3464cb2..f3c93c5e805 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -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', @@ -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', diff --git a/packages/api/src/client/types/app/bsky/graph/getSuggestedFollowsByActor.ts b/packages/api/src/client/types/app/bsky/graph/getSuggestedFollowsByActor.ts new file mode 100644 index 00000000000..8ff7ed414cb --- /dev/null +++ b/packages/api/src/client/types/app/bsky/graph/getSuggestedFollowsByActor.ts @@ -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 +} diff --git a/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts b/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts new file mode 100644 index 00000000000..be42ce2b959 --- /dev/null +++ b/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts @@ -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 +} diff --git a/packages/bsky/src/api/index.ts b/packages/bsky/src/api/index.ts index ec64c2236bf..1928cda01d2 100644 --- a/packages/bsky/src/api/index.ts +++ b/packages/bsky/src/api/index.ts @@ -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' @@ -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) diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index 028b3cbf397..93435056503 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -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' @@ -1251,6 +1252,17 @@ export class GraphNS { return this._server.xrpc.method(nsid, cfg) } + getSuggestedFollowsByActor( + cfg: ConfigOf< + AV, + AppBskyGraphGetSuggestedFollowsByActor.Handler>, + AppBskyGraphGetSuggestedFollowsByActor.HandlerReqCtx> + >, + ) { + const nsid = 'app.bsky.graph.getSuggestedFollowsByActor' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + muteActor( cfg: ConfigOf< AV, diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 30da3464cb2..f3c93c5e805 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -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', @@ -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', diff --git a/packages/bsky/src/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.ts b/packages/bsky/src/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.ts new file mode 100644 index 00000000000..a2245846fd2 --- /dev/null +++ b/packages/bsky/src/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.ts @@ -0,0 +1,46 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' +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 type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/bsky/tests/seeds/likes.ts b/packages/bsky/tests/seeds/likes.ts index 27eeba09c40..1747fb2fa59 100644 --- a/packages/bsky/tests/seeds/likes.ts +++ b/packages/bsky/tests/seeds/likes.ts @@ -10,5 +10,34 @@ export default async (sc: SeedClient) => { }) await sc.like(sc.dids.eve, sc.posts[sc.dids.alice][1].ref) await sc.like(sc.dids.carol, sc.replies[sc.dids.bob][0].ref) + + // give alice > 100 likes + for (let i = 0; i < 50; i++) { + const [b, c, d] = await Promise.all([ + sc.post(sc.dids.bob, `bob post ${i}`), + sc.post(sc.dids.carol, `carol post ${i}`), + sc.post(sc.dids.dan, `dan post ${i}`), + ]) + await Promise.all( + [ + sc.like(sc.dids.alice, b.ref), // likes 50 of bobs posts + i < 45 && sc.like(sc.dids.alice, c.ref), // likes 45 of carols posts + i < 40 && sc.like(sc.dids.alice, d.ref), // likes 40 of dans posts + ].filter(Boolean), + ) + } + + // couple more NPCs for suggested follows + await sc.createAccount('fred', { + email: 'fred@test.com', + handle: 'fred.test', + password: 'fred-pass', + }) + await sc.createAccount('gina', { + email: 'gina@test.com', + handle: 'gina.test', + password: 'gina-pass', + }) + return sc } diff --git a/packages/bsky/tests/views/suggested-follows.test.ts b/packages/bsky/tests/views/suggested-follows.test.ts new file mode 100644 index 00000000000..6a2f3ebe1d7 --- /dev/null +++ b/packages/bsky/tests/views/suggested-follows.test.ts @@ -0,0 +1,147 @@ +import AtpAgent, { AtUri } from '@atproto/api' +import { TestNetwork } from '@atproto/dev-env' +import { SeedClient } from '../seeds/client' +import likesSeed from '../seeds/likes' + +describe('suggested follows', () => { + let network: TestNetwork + let agent: AtpAgent + let pdsAgent: AtpAgent + let sc: SeedClient + + beforeAll(async () => { + network = await TestNetwork.create({ + dbPostgresSchema: 'bsky_views_suggestions', + }) + agent = network.bsky.getClient() + pdsAgent = network.pds.getClient() + sc = new SeedClient(pdsAgent) + await likesSeed(sc) + await network.processAll() + await network.bsky.processAll() + + const suggestions = [ + { did: sc.dids.alice, order: 1 }, + { did: sc.dids.bob, order: 2 }, + { did: sc.dids.carol, order: 3 }, + { did: sc.dids.dan, order: 4 }, + { did: sc.dids.fred, order: 5 }, + { did: sc.dids.gina, order: 6 }, + ] + await network.bsky.ctx.db + .getPrimary() + .db.insertInto('suggested_follow') + .values(suggestions) + .execute() + }) + + afterAll(async () => { + await network.close() + }) + + it('returns sorted suggested follows for carol', async () => { + const result = await agent.api.app.bsky.graph.getSuggestedFollowsByActor( + { + actor: sc.dids.alice, + }, + { headers: await network.serviceHeaders(sc.dids.carol) }, + ) + + expect(result.data.suggestions.length).toBe(4) // backfilled with 2 NPCs + expect( + result.data.suggestions.find((sug) => { + return [sc.dids.alice, sc.dids.carol].includes(sug.did) + }), + ).toBeFalsy() // not actor or viewer + }) + + it('returns sorted suggested follows for fred', async () => { + const result = await agent.api.app.bsky.graph.getSuggestedFollowsByActor( + { + actor: sc.dids.alice, + }, + { headers: await network.serviceHeaders(sc.dids.fred) }, + ) + + expect(result.data.suggestions.length).toBe(4) // backfilled with 2 NPCs + expect( + result.data.suggestions.find((sug) => { + return [sc.dids.fred, sc.dids.alice].includes(sug.did) + }), + ).toBeFalsy() // not actor or viewer or followed + }) + + it('exludes users muted by viewer', async () => { + await pdsAgent.api.app.bsky.graph.muteActor( + { actor: sc.dids.bob }, + { headers: sc.getHeaders(sc.dids.carol), encoding: 'application/json' }, + ) + const result = await agent.api.app.bsky.graph.getSuggestedFollowsByActor( + { + actor: sc.dids.alice, + }, + { headers: await network.serviceHeaders(sc.dids.carol) }, + ) + + expect( + result.data.suggestions.find((sug) => { + return [sc.dids.alice, sc.dids.carol, sc.dids.bob].includes(sug.did) + }), + ).toBeFalsy() // not actor or viewer or muted + + await pdsAgent.api.app.bsky.graph.muteActor( + { actor: sc.dids.bob }, + { headers: sc.getHeaders(sc.dids.carol), encoding: 'application/json' }, + ) + }) + + it('exludes users blocked by viewer', async () => { + const carolBlocksBob = await pdsAgent.api.app.bsky.graph.block.create( + { repo: sc.dids.carol }, + { createdAt: new Date().toISOString(), subject: sc.dids.bob }, + sc.getHeaders(sc.dids.carol), + ) + const result = await agent.api.app.bsky.graph.getSuggestedFollowsByActor( + { + actor: sc.dids.alice, + }, + { headers: await network.serviceHeaders(sc.dids.carol) }, + ) + + expect( + result.data.suggestions.find((sug) => { + return [sc.dids.alice, sc.dids.carol, sc.dids.bob].includes(sug.did) + }), + ).toBeFalsy() // not actor or viewer or muted + + await pdsAgent.api.app.bsky.graph.block.delete( + { repo: sc.dids.carol, rkey: new AtUri(carolBlocksBob.uri).rkey }, + sc.getHeaders(sc.dids.carol), + ) + }) + + it('exludes users blocking viewer', async () => { + const bobBlocksCarol = await pdsAgent.api.app.bsky.graph.block.create( + { repo: sc.dids.bob }, + { createdAt: new Date().toISOString(), subject: sc.dids.carol }, + sc.getHeaders(sc.dids.bob), + ) + const result = await agent.api.app.bsky.graph.getSuggestedFollowsByActor( + { + actor: sc.dids.alice, + }, + { headers: await network.serviceHeaders(sc.dids.carol) }, + ) + + expect( + result.data.suggestions.find((sug) => { + return [sc.dids.alice, sc.dids.carol, sc.dids.bob].includes(sug.did) + }), + ).toBeFalsy() // not actor or viewer or muted + + await pdsAgent.api.app.bsky.graph.block.delete( + { repo: sc.dids.bob, rkey: new AtUri(bobBlocksCarol.uri).rkey }, + sc.getHeaders(sc.dids.bob), + ) + }) +}) diff --git a/packages/pds/src/app-view/api/app/bsky/graph/getSuggestedFollowsByActor.ts b/packages/pds/src/app-view/api/app/bsky/graph/getSuggestedFollowsByActor.ts new file mode 100644 index 00000000000..dfafa6b65ea --- /dev/null +++ b/packages/pds/src/app-view/api/app/bsky/graph/getSuggestedFollowsByActor.ts @@ -0,0 +1,20 @@ +import { Server } from '../../../../../lexicon' +import AppContext from '../../../../../context' + +export default function (server: Server, ctx: AppContext) { + server.app.bsky.graph.getSuggestedFollowsByActor({ + auth: ctx.accessVerifier, + handler: async ({ auth, params }) => { + const requester = auth.credentials.did + const res = + await ctx.appviewAgent.api.app.bsky.graph.getSuggestedFollowsByActor( + params, + await ctx.serviceAuthHeaders(requester), + ) + return { + encoding: 'application/json', + body: res.data, + } + }, + }) +} diff --git a/packages/pds/src/app-view/api/app/bsky/index.ts b/packages/pds/src/app-view/api/app/bsky/index.ts index 7f9c458ae70..5cffb90653d 100644 --- a/packages/pds/src/app-view/api/app/bsky/index.ts +++ b/packages/pds/src/app-view/api/app/bsky/index.ts @@ -27,6 +27,7 @@ import muteActor from './graph/muteActor' import muteActorList from './graph/muteActorList' import unmuteActor from './graph/unmuteActor' import unmuteActorList from './graph/unmuteActorList' +import getSuggestedFollowsByActor from './graph/getSuggestedFollowsByActor' import getUsersSearch from './actor/searchActors' import getUsersTypeahead from './actor/searchActorsTypeahead' import getSuggestions from './actor/getSuggestions' @@ -64,6 +65,7 @@ export default function (server: Server, ctx: AppContext) { muteActorList(server, ctx) unmuteActor(server, ctx) unmuteActorList(server, ctx) + getSuggestedFollowsByActor(server, ctx) getUsersSearch(server, ctx) getUsersTypeahead(server, ctx) getSuggestions(server, ctx) diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index 028b3cbf397..93435056503 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -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' @@ -1251,6 +1252,17 @@ export class GraphNS { return this._server.xrpc.method(nsid, cfg) } + getSuggestedFollowsByActor( + cfg: ConfigOf< + AV, + AppBskyGraphGetSuggestedFollowsByActor.Handler>, + AppBskyGraphGetSuggestedFollowsByActor.HandlerReqCtx> + >, + ) { + const nsid = 'app.bsky.graph.getSuggestedFollowsByActor' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + muteActor( cfg: ConfigOf< AV, diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 30da3464cb2..f3c93c5e805 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -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', @@ -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', diff --git a/packages/pds/src/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.ts b/packages/pds/src/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.ts new file mode 100644 index 00000000000..a2245846fd2 --- /dev/null +++ b/packages/pds/src/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.ts @@ -0,0 +1,46 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' +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 type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput