diff --git a/packages/client/package.json b/packages/client/package.json index dd4036d..ccf90df 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -21,22 +21,24 @@ "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "dev": "pkgroll --watch", - "build": "pkgroll", + "build": "pkgroll --minify", "test": "vitest", "test:typescript": "tsc --noEmit" }, "dependencies": { - "@atcute/client": "^2.0.6", - "@tsky/lexicons": "workspace:*" + "@atcute/client": "^2.0.6" }, "devDependencies": { + "@tsky/lexicons": "workspace:*", "globals": "^15.12.0", "pkgroll": "^2.5.1", "tsx": "^4.19.2", "typescript": "^5.7.2", "vitest": "^2.1.6" } -} +} \ No newline at end of file diff --git a/packages/client/src/auth/auth.ts b/packages/client/src/auth/auth.ts new file mode 100644 index 0000000..a1f969c --- /dev/null +++ b/packages/client/src/auth/auth.ts @@ -0,0 +1,45 @@ +import { + type AtpSessionData, + CredentialManager, + type CredentialManagerOptions, +} from '@atcute/client'; + +export class Auth { + manager: CredentialManager; + sessions: Map = new Map(); + + constructor(options?: CredentialManagerOptions) { + this.manager = new CredentialManager( + options ?? { service: 'https://bsky.social' }, + ); + } + + async login(identifier: string, password: string) { + const session = await this.manager.login({ + identifier, + password, + }); + + this.sessions.set(session.did, session); + + return session; + } + + async switch(did: string) { + const session = this.sessions.get(did); + + if (!session) { + throw new Error('Session not found'); + } + + return await this.manager.resume(session); + } + + logout(did: string) { + this.sessions.delete(did); + } + + get currentSession() { + return this.manager.session; + } +} diff --git a/packages/client/src/auth/index.ts b/packages/client/src/auth/index.ts new file mode 100644 index 0000000..269586e --- /dev/null +++ b/packages/client/src/auth/index.ts @@ -0,0 +1 @@ +export * from './auth'; diff --git a/packages/client/src/bsky/actor/actor.ts b/packages/client/src/bsky/actor/actor.ts new file mode 100644 index 0000000..94cd3ae --- /dev/null +++ b/packages/client/src/bsky/actor/actor.ts @@ -0,0 +1,142 @@ +import type { + AppBskyActorDefs, + AppBskyFeedGetAuthorFeed, +} from '@tsky/lexicons'; +import type { Client } from '~/tsky/client'; +import type { RPCOptions } from '~/types'; +import { Paginator } from '~/utils'; + +export class Actor { + client: Client; + identifier: string; + + constructor(client: Client, identifier: string) { + this.client = client; + this.identifier = identifier; + } + + /** + * Get detailed profile view of an actor. Does not require auth, but contains relevant metadata with auth. + */ + async profile(): Promise { + const res = await this.client.get('app.bsky.actor.getProfile', { + params: { actor: this.identifier }, + }); + + return res.data; + } + + /** + * Get a list of starter packs created by the actor. + */ + starterPacks(limit?: number, options: RPCOptions = {}) { + return Paginator.init(async (cursor) => { + const res = await this.client.get('app.bsky.graph.getActorStarterPacks', { + params: { cursor, actor: this.identifier, limit }, + ...options, + }); + + return res.data; + }); + } + + /** + * Enumerates accounts which follow a specified account (actor). + */ + followers(limit?: number, options: RPCOptions = {}) { + return Paginator.init(async (cursor) => { + const res = await this.client.get('app.bsky.graph.getFollowers', { + params: { + cursor, + actor: this.identifier, + limit, + }, + ...options, + }); + + return res.data; + }); + } + + /** + * Enumerates accounts which a specified account (actor) follows. + */ + follows(limit?: number, options: RPCOptions = {}) { + return Paginator.init(async (cursor) => { + const res = await this.client.get('app.bsky.graph.getFollows', { + params: { + cursor, + actor: this.identifier, + limit, + }, + ...options, + }); + + return res.data; + }); + } + + /** + * Enumerates the lists created by a specified account (actor). + */ + lists(limit?: number, options: RPCOptions = {}) { + return Paginator.init(async (cursor) => { + const res = await this.client.get('app.bsky.graph.getLists', { + params: { + cursor, + actor: this.identifier, + limit, + }, + ...options, + }); + + return res.data; + }); + } + + /** + * Enumerates public relationships between one account, and a list of other accounts. Does not require auth. + */ + async relationships(others?: string[], options?: RPCOptions) { + const res = await this.client.get('app.bsky.graph.getRelationships', { + params: { + actor: this.identifier, + others, + }, + ...options, + }); + + return res.data; + } + + /** + * Get a view of an actor's 'author feed' (post and reposts by the author). Does not require auth. + */ + feeds(limit?: number, options?: RPCOptions) { + return Paginator.init(async (cursor) => { + const res = await this.client.get('app.bsky.feed.getActorFeeds', { + params: { cursor, actor: this.identifier, limit }, + ...options, + }); + + return res.data; + }); + } + + /** + * Get a list of feeds (feed generator records) created by the actor (in the actor's repo). + */ + feed( + params?: Omit, + options?: RPCOptions, + ) { + return Paginator.init(async (cursor) => { + const res = await this.client.get('app.bsky.feed.getAuthorFeed', { + params: { cursor, ...params, actor: this.identifier }, + ...options, + }); + + return res.data; + }); + } +} diff --git a/packages/client/src/bsky/actor/index.ts b/packages/client/src/bsky/actor/index.ts new file mode 100644 index 0000000..bce1ce5 --- /dev/null +++ b/packages/client/src/bsky/actor/index.ts @@ -0,0 +1 @@ +export * from './actor'; diff --git a/packages/client/src/bsky/bsky.ts b/packages/client/src/bsky/bsky.ts new file mode 100644 index 0000000..813677b --- /dev/null +++ b/packages/client/src/bsky/bsky.ts @@ -0,0 +1,20 @@ +import { Feed } from '~/bsky/feed'; +import type { Client } from '~/tsky/client'; +import { Actor } from './actor'; +import { List } from './list'; + +export class Bsky { + constructor(private client: Client) {} + + actor(identifier: string) { + return new Actor(this.client, identifier); + } + + list(uri: string) { + return new List(this.client, uri); + } + + get feed() { + return new Feed(this.client); + } +} diff --git a/packages/client/src/bsky/feed/feed.test.ts b/packages/client/src/bsky/feed/feed.test.ts new file mode 100644 index 0000000..40f88bf --- /dev/null +++ b/packages/client/src/bsky/feed/feed.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { Tsky } from '~/index'; + +const TEST_CREDENTIALS = { + alice: { + handle: 'alice.tsky.dev', + did: 'did:plc:jguhdmnjclquqf5lsvkyxqy3', + password: 'alice_and_bob', + }, + bob: { + handle: 'bob.tsky.dev', + did: 'did:plc:2ig7akkyfq256j42uxvc4g2h', + password: 'alice_and_bob', + }, +}; + +async function getAliceTsky() { + const tsky = new Tsky(); + + await tsky.auth.login( + TEST_CREDENTIALS.alice.handle, + TEST_CREDENTIALS.alice.password, + ); + + return tsky; +} + +describe('feed', () => { + it('.getFeed()', async () => { + const tsky = await getAliceTsky(); + const paginator = await tsky.bsky.feed.get({ + // "Birds! 🦉" custom feed + // - https://bsky.app/profile/daryllmarie.bsky.social/feed/aaagllxbcbsje + feed: 'at://did:plc:ffkgesg3jsv2j7aagkzrtcvt/app.bsky.feed.generator/aaagllxbcbsje', + limit: 30, + }); + expect(paginator).toBeDefined(); + expect(paginator.values).toBeDefined(); + expect(paginator.values).toBeInstanceOf(Array); + expect(paginator.values.length).toBe(1); // we should get the first page from the paginator + expect(paginator.values[0].feed.length).toBeGreaterThan(0); // we found some birds posts ;) + expect(paginator.values[0].feed[0]).toHaveProperty('post'); + }); +}); diff --git a/packages/client/src/bsky/feed.ts b/packages/client/src/bsky/feed/feed.ts similarity index 52% rename from packages/client/src/bsky/feed.ts rename to packages/client/src/bsky/feed/feed.ts index ffc1a30..7d0aacb 100644 --- a/packages/client/src/bsky/feed.ts +++ b/packages/client/src/bsky/feed/feed.ts @@ -1,9 +1,11 @@ import type { AppBskyFeedGetFeed, - AppBskyFeedGetTimeline, + AppBskyFeedSendInteractions, } from '@tsky/lexicons'; import type { Client } from '~/tsky/client'; -import { Paginator } from '~/tsky/paginator'; +import type { RPCOptions } from '~/types'; +import { Paginator } from '~/utils'; +import { FeedGenerator } from './generator'; export class Feed { constructor(private client: Client) {} @@ -11,7 +13,7 @@ export class Feed { /** * Get a hydrated feed from an actor's selected feed generator. Implemented by App View. */ - async getFeed( + async get( params: AppBskyFeedGetFeed.Params, options?: AppBskyFeedGetFeed.Input, ): Promise> { @@ -29,22 +31,21 @@ export class Feed { } /** - * Get a view of the requesting account's home timeline. This is expected to be some form of reverse-chronological feed. + * Send information about interactions with feed items back to the feed generator that served them. */ - getTimeline( - params: AppBskyFeedGetTimeline.Params, - options?: AppBskyFeedGetTimeline.Input, - ): Promise> { - return Paginator.init(async (cursor) => { - const res = await this.client.get('app.bsky.feed.getTimeline', { - ...(options ?? {}), - params: { - cursor, - ...params, - }, - }); - - return res.data; + async sendInteractions( + interactions: AppBskyFeedSendInteractions.Input['interactions'], + options: RPCOptions = {}, + ) { + const res = await this.client.call('app.bsky.feed.sendInteractions', { + data: { interactions }, + ...options, }); + + return res.data; + } + + generator() { + return new FeedGenerator(this.client); } } diff --git a/packages/client/src/bsky/feed/generator.ts b/packages/client/src/bsky/feed/generator.ts new file mode 100644 index 0000000..f2d5064 --- /dev/null +++ b/packages/client/src/bsky/feed/generator.ts @@ -0,0 +1,76 @@ +import type { + AppBskyFeedGetFeedGenerator, + AppBskyFeedGetFeedGenerators, + AppBskyFeedGetFeedSkeleton, +} from '@tsky/lexicons'; +import type { Client } from '~/tsky/client'; +import type { RPCOptions } from '~/types'; +import { Paginator } from '~/utils'; + +export class FeedGenerator { + constructor(private client: Client) {} + + /** + * Get information about a feed generator, including policies and offered feed URIs. Does not require auth; implemented by Feed Generator services (not App View). + */ + async describe(options: RPCOptions = {}) { + const res = await this.client.get( + 'app.bsky.feed.describeFeedGenerator', + options, + ); + + return res.data; + } + + /** + * Get information about a feed generator. Implemented by AppView. + */ + feed( + feed: string, + options: RPCOptions, + ): Promise; + /** + * Get information about a list of feed generators. + */ + feed( + feeds: string[], + options: RPCOptions, + ): Promise; + + async feed(feed: string | string[], options: RPCOptions) { + if (Array.isArray(feed)) { + const res = await this.client.get('app.bsky.feed.getFeedGenerators', { + params: { + feeds: feed, + }, + ...options, + }); + + return res.data.feeds; + } + + const res = await this.client.get('app.bsky.feed.getFeedGenerator', { + params: { feed }, + ...options, + }); + + return res.data; + } + + /** + * Get a skeleton of a feed provided by a feed generator. Auth is optional, depending on provider requirements, and provides the DID of the requester. Implemented by Feed Generator Service. + */ + skeleton( + params: AppBskyFeedGetFeedSkeleton.Params, + options: RPCOptions = {}, + ) { + return Paginator.init(async (cursor) => { + const res = await this.client.get('app.bsky.feed.getFeedSkeleton', { + params: { cursor, ...params }, + ...options, + }); + + return res.data; + }); + } +} diff --git a/packages/client/src/bsky/feed/index.ts b/packages/client/src/bsky/feed/index.ts new file mode 100644 index 0000000..0b2a3ad --- /dev/null +++ b/packages/client/src/bsky/feed/index.ts @@ -0,0 +1 @@ +export * from './feed'; diff --git a/packages/client/src/bsky/index.test.ts b/packages/client/src/bsky/index.test.ts deleted file mode 100644 index 4e68c4c..0000000 --- a/packages/client/src/bsky/index.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { CredentialManager } from '@atcute/client'; -import { describe, expect, it } from 'vitest'; -import { Tsky } from '~/index'; - -const TEST_CREDENTIALS = { - alice: { - handle: 'alice.tsky.dev', - did: 'did:plc:jguhdmnjclquqf5lsvkyxqy3', - password: 'alice_and_bob', - }, - bob: { - handle: 'bob.tsky.dev', - did: 'did:plc:2ig7akkyfq256j42uxvc4g2h', - password: 'alice_and_bob', - }, -}; - -async function getAliceTsky() { - const manager = new CredentialManager({ service: 'https://bsky.social' }); - await manager.login({ - identifier: TEST_CREDENTIALS.alice.handle, - password: TEST_CREDENTIALS.alice.password, - }); - - return new Tsky(manager); -} - -describe('bsky', () => { - it('.profile()', async () => { - const tsky = await getAliceTsky(); - const profile = await tsky.bsky.profile(TEST_CREDENTIALS.alice.did); - - expect(profile).toBeDefined(); - expect(profile).toHaveProperty('handle', TEST_CREDENTIALS.alice.handle); - }); - - describe('feed', () => { - it('.timeline()', async () => { - const tsky = await getAliceTsky(); - - const paginator = await tsky.bsky.feed.getTimeline({ - limit: 30, - }); - - expect(paginator).toBeDefined(); - expect(paginator.values).toBeDefined(); - expect(paginator.values).toBeInstanceOf(Array); - expect(paginator.values.length).toBe(1); // we should get the first page from the paginator - expect(paginator.values[0].feed.length).toBeGreaterThan(0); // alice has some posts ;) - expect(paginator.values[0].feed[0]).toHaveProperty('post'); - }); - - it('.feed()', async () => { - const tsky = await getAliceTsky(); - - const paginator = await tsky.bsky.feed.getFeed({ - // "Birds! 🦉" custom feed - // - https://bsky.app/profile/daryllmarie.bsky.social/feed/aaagllxbcbsje - feed: 'at://did:plc:ffkgesg3jsv2j7aagkzrtcvt/app.bsky.feed.generator/aaagllxbcbsje', - limit: 30, - }); - - expect(paginator).toBeDefined(); - expect(paginator.values).toBeDefined(); - expect(paginator.values).toBeInstanceOf(Array); - expect(paginator.values.length).toBe(1); // we should get the first page from the paginator - expect(paginator.values[0].feed.length).toBeGreaterThan(0); // we found some birds posts ;) - expect(paginator.values[0].feed[0]).toHaveProperty('post'); - }); - }); -}); diff --git a/packages/client/src/bsky/index.ts b/packages/client/src/bsky/index.ts index 61df734..a29617f 100644 --- a/packages/client/src/bsky/index.ts +++ b/packages/client/src/bsky/index.ts @@ -1,28 +1,2 @@ -import type { AppBskyActorDefs } from '@tsky/lexicons'; -import { Feed } from '~/bsky/feed'; -import type { Client } from '~/tsky/client'; - -export class Bsky { - client: Client; - - constructor(client: Client) { - this.client = client; - } - - /** - * Get detailed profile view of an actor. Does not require auth, but contains relevant metadata with auth. - */ - async profile( - identifier: string, - ): Promise { - const res = await this.client.get('app.bsky.actor.getProfile', { - params: { actor: identifier }, - }); - - return res.data; - } - - get feed() { - return new Feed(this.client); - } -} +export * from './bsky'; +export * from './feed'; diff --git a/packages/client/src/bsky/list/index.ts b/packages/client/src/bsky/list/index.ts new file mode 100644 index 0000000..7182513 --- /dev/null +++ b/packages/client/src/bsky/list/index.ts @@ -0,0 +1 @@ +export * from './list'; diff --git a/packages/client/src/bsky/list/list.ts b/packages/client/src/bsky/list/list.ts new file mode 100644 index 0000000..313b403 --- /dev/null +++ b/packages/client/src/bsky/list/list.ts @@ -0,0 +1,46 @@ +import type { Client } from '~/tsky/client'; +import type { RPCOptions } from '~/types'; +import { Paginator } from '~/utils'; + +export class List { + constructor( + private client: Client, + private uri: string, + ) {} + + /** + * Gets a 'view' (with additional context) of a specified list. + */ + about(limit?: number, options?: RPCOptions) { + return Paginator.init(async (cursor) => { + const res = await this.client.get('app.bsky.graph.getList', { + params: { + cursor, + list: this.uri, + limit, + }, + ...options, + }); + + return res.data; + }); + } + + /** + * Get a feed of recent posts from a list (posts and reposts from any actors on the list). Does not require auth. + */ + feed(limit?: number, options?: RPCOptions) { + return Paginator.init(async (cursor) => { + const res = await this.client.get('app.bsky.feed.getListFeed', { + params: { + cursor, + list: this.uri, + limit, + }, + ...options, + }); + + return res.data; + }); + } +} diff --git a/packages/client/src/bsky/post/index.ts b/packages/client/src/bsky/post/index.ts new file mode 100644 index 0000000..336abe1 --- /dev/null +++ b/packages/client/src/bsky/post/index.ts @@ -0,0 +1 @@ +export * from './post'; diff --git a/packages/client/src/bsky/post/post.ts b/packages/client/src/bsky/post/post.ts new file mode 100644 index 0000000..41112a6 --- /dev/null +++ b/packages/client/src/bsky/post/post.ts @@ -0,0 +1,108 @@ +import type { + AppBskyFeedGetLikes, + AppBskyFeedGetPostThread, + AppBskyFeedGetQuotes, + AppBskyFeedGetRepostedBy, + AppBskyFeedSearchPosts, +} from '@tsky/lexicons'; +import type { Client } from '~/tsky/client'; +import type { RPCOptions } from '~/types'; +import { Paginator } from '~/utils'; + +export class Post { + constructor(private client: Client) {} + + /** + * Get posts in a thread. Does not require auth, but additional metadata and filtering will be applied for authed requests. + */ + async threads( + params: AppBskyFeedGetPostThread.Params, + options: RPCOptions = {}, + ) { + const res = await this.client.get('app.bsky.feed.getPostThread', { + params, + ...options, + }); + + return res.data; + } + + /** + * Get like records which reference a subject (by AT-URI and CID). + */ + likes(params: AppBskyFeedGetLikes.Params, options: RPCOptions = {}) { + return Paginator.init(async (cursor) => { + const res = await this.client.get('app.bsky.feed.getLikes', { + params: { cursor, ...params }, + ...options, + }); + + return res.data; + }); + } + + /** + * Get a list of quotes for a given post. + */ + quotes(params: AppBskyFeedGetQuotes.Params, options: RPCOptions = {}) { + return Paginator.init(async (cursor) => { + const res = await this.client.get('app.bsky.feed.getQuotes', { + params: { cursor, ...params }, + ...options, + }); + + return res.data; + }); + } + + /** + * Get a list of reposts for a given post. + */ + repostedBy( + params: AppBskyFeedGetRepostedBy.Params, + options: RPCOptions = {}, + ) { + return Paginator.init(async (cursor) => { + const res = await this.client.get('app.bsky.feed.getRepostedBy', { + params: { cursor, ...params }, + ...options, + }); + + return res.data; + }); + } + + /** + * Find posts matching search criteria, returning views of those posts. + */ + static search( + client: Client, + params: AppBskyFeedSearchPosts.Params, + options: RPCOptions = {}, + ) { + return Paginator.init(async (cursor) => { + const res = await client.get('app.bsky.feed.searchPosts', { + params: { cursor, ...params }, + ...options, + }); + + return res.data; + }); + } + + /** + * Gets post views for a specified list of posts (by AT-URI). This is sometimes referred to as 'hydrating' a 'feed skeleton'. + */ + static async getMany( + client: Client, + posts: string[], + options: RPCOptions = {}, + ) { + const res = await client.get('app.bsky.feed.getPosts', { + params: { uris: posts }, + ...options, + }); + + return res.data.posts; + } +} diff --git a/packages/client/src/starterpack/index.ts b/packages/client/src/starterpack/index.ts new file mode 100644 index 0000000..e55a57e --- /dev/null +++ b/packages/client/src/starterpack/index.ts @@ -0,0 +1 @@ +export * from './starterpack'; diff --git a/packages/client/src/starterpack/starterpack.ts b/packages/client/src/starterpack/starterpack.ts new file mode 100644 index 0000000..dc2e559 --- /dev/null +++ b/packages/client/src/starterpack/starterpack.ts @@ -0,0 +1,64 @@ +import type { + AppBskyGraphGetStarterPack, + AppBskyGraphGetStarterPacks, +} from '@tsky/lexicons'; +import type { Client } from '~/tsky/client'; +import type { RPCOptions } from '~/types'; +import { Paginator } from '~/utils'; + +export class StarterPack { + constructor(private client: Client) {} + + /** + * Gets a view of a starter pack. + */ + view( + uri: string, + options: RPCOptions, + ): Promise; + /** + * Get views for a list of starter packs. + */ + view( + uris: string[], + options: RPCOptions, + ): Promise; + + async view(uris: string | string[], options: RPCOptions) { + if (Array.isArray(uris)) { + const res = await this.client.get('app.bsky.graph.getStarterPacks', { + params: { + uris, + }, + ...options, + }); + + return res.data.starterPacks; + } + + const res = await this.client.get('app.bsky.graph.getStarterPack', { + params: { starterPack: uris }, + ...options, + }); + + return res.data; + } + + /** + * Search for starter packs. + */ + search(query: string, limit?: number, options?: RPCOptions) { + return Paginator.init(async (cursor) => { + const res = await this.client.get('app.bsky.graph.searchStarterPacks', { + params: { + cursor, + q: query, + limit, + }, + ...options, + }); + + return res.data; + }); + } +} diff --git a/packages/client/src/tsky/index.ts b/packages/client/src/tsky/index.ts index 3ef8518..615ea17 100644 --- a/packages/client/src/tsky/index.ts +++ b/packages/client/src/tsky/index.ts @@ -1,2 +1 @@ -export * from './paginator'; export * from './tsky'; diff --git a/packages/client/src/tsky/tsky.ts b/packages/client/src/tsky/tsky.ts index c6a3fc5..a19fa76 100644 --- a/packages/client/src/tsky/tsky.ts +++ b/packages/client/src/tsky/tsky.ts @@ -1,18 +1,46 @@ -import type { CredentialManager } from '@atcute/client'; import { XRPC } from '@atcute/client'; import type { Queries } from '@tsky/lexicons'; +import { Auth } from '~/auth'; import { Bsky } from '~/bsky'; +import { StarterPack } from '~/starterpack'; +import { User } from '~/user'; +import { Video } from '~/video'; import { Client } from './client'; export class Tsky { - client: Client; + auth: Auth; + private client: Client; - constructor(manager: CredentialManager) { - const xrpc = new XRPC({ handler: manager }); + constructor() { + // Initialize the auth manager + this.auth = new Auth(); + + // Initialize the client + const xrpc = new XRPC({ handler: this.auth.manager }); this.client = new Client(xrpc); } + get user() { + if (!this.auth.currentSession) { + throw new Error('There is no active session'); + } + + return new User(this.client, this.auth.currentSession.handle); + } + get bsky() { return new Bsky(this.client); } + + get video() { + if (!this.auth.currentSession) { + throw new Error('There is no active session'); + } + + return new Video(this.client); + } + + get starterpack() { + return new StarterPack(this.client); + } } diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts new file mode 100644 index 0000000..5819b72 --- /dev/null +++ b/packages/client/src/types.ts @@ -0,0 +1 @@ +export type RPCOptions = { signal?: AbortSignal; headers?: HeadersInit }; diff --git a/packages/client/src/user/index.ts b/packages/client/src/user/index.ts new file mode 100644 index 0000000..99e2809 --- /dev/null +++ b/packages/client/src/user/index.ts @@ -0,0 +1,65 @@ +import type { AppBskyFeedGetTimeline } from '@tsky/lexicons'; +import { Actor } from '~/bsky/actor'; +import type { RPCOptions } from '~/types'; +import { Paginator } from '~/utils'; +import { Mute } from './mute'; +import { Muted } from './muted'; +import { Preferences } from './preferences'; +import { Suggestion } from './suggestion'; +import { Unmute } from './unmute'; + +export class User extends Actor { + get preferences() { + return new Preferences(this.client); + } + + /** + * Get a view of the requesting account's home timeline. This is expected to be some form of reverse-chronological feed. + */ + timeline( + params: AppBskyFeedGetTimeline.Params, + options?: AppBskyFeedGetTimeline.Input, + ): Promise> { + return Paginator.init(async (cursor) => { + const res = await this.client.get('app.bsky.feed.getTimeline', { + ...(options ?? {}), + params: { + cursor, + ...params, + }, + }); + + return res.data; + }); + } + + /** + * Get a list of posts liked by the current user + */ + likes(limit?: number, options: RPCOptions = {}) { + return Paginator.init(async (cursor) => { + const res = await this.client.get('app.bsky.feed.getActorLikes', { + params: { cursor, actor: this.identifier, limit }, + ...options, + }); + + return res.data; + }); + } + + get muted() { + return new Muted(this.client); + } + + get suggestion() { + return new Suggestion(this.client); + } + + get mute() { + return new Mute(this.client); + } + + get unmute() { + return new Unmute(this.client); + } +} diff --git a/packages/client/src/user/mute/index.ts b/packages/client/src/user/mute/index.ts new file mode 100644 index 0000000..c456722 --- /dev/null +++ b/packages/client/src/user/mute/index.ts @@ -0,0 +1 @@ +export * from './mute'; diff --git a/packages/client/src/user/mute/mute.ts b/packages/client/src/user/mute/mute.ts new file mode 100644 index 0000000..a6c65d8 --- /dev/null +++ b/packages/client/src/user/mute/mute.ts @@ -0,0 +1,37 @@ +import type { Client } from '~/tsky/client'; +import type { RPCOptions } from '~/types'; + +export class Mute { + constructor(private client: Client) {} + + /** + * Creates a mute relationship for the specified account. Mutes are private in Bluesky. + */ + actor(identifier: string, options: RPCOptions = {}) { + return this.client.call('app.bsky.graph.muteActor', { + data: { actor: identifier }, + ...options, + }); + } + + /** + * Mutes a thread preventing notifications from the thread and any of its children. Mutes are private in Bluesky. + */ + thread(identifier: string, options: RPCOptions = {}) { + return this.client.call('app.bsky.graph.muteThread', { + data: { root: identifier }, + ...options, + }); + } + + /** + * Mute an entire list (specified by AT-URI) of actors. This creates a mute relationship for all actors + * on the specified list. Mutes are private on Bluesky. + */ + actorList(identifier: string, options: RPCOptions = {}) { + return this.client.call('app.bsky.graph.muteActorList', { + data: { list: identifier }, + ...options, + }); + } +} diff --git a/packages/client/src/user/muted/index.ts b/packages/client/src/user/muted/index.ts new file mode 100644 index 0000000..c0af9b3 --- /dev/null +++ b/packages/client/src/user/muted/index.ts @@ -0,0 +1 @@ +export * from './muted'; diff --git a/packages/client/src/user/muted/muted.ts b/packages/client/src/user/muted/muted.ts new file mode 100644 index 0000000..4c81219 --- /dev/null +++ b/packages/client/src/user/muted/muted.ts @@ -0,0 +1,41 @@ +import type { Client } from '~/tsky/client'; +import type { RPCOptions } from '~/types'; +import { Paginator } from '~/utils'; + +export class Muted { + constructor(private client: Client) {} + + /** + * Enumerates mod lists that the requesting account (actor) currently has muted. Requires auth. + */ + lists(limit?: number, options?: RPCOptions) { + return Paginator.init(async (cursor) => { + const res = await this.client.get('app.bsky.graph.getListMutes', { + params: { + cursor, + limit, + }, + ...options, + }); + + return res.data; + }); + } + + /** + * Enumerates accounts that the requesting account (actor) currently has muted. Requires auth. + */ + profiles(limit?: number, options?: RPCOptions) { + return Paginator.init(async (cursor) => { + const res = await this.client.get('app.bsky.graph.getMutes', { + params: { + cursor, + limit, + }, + ...options, + }); + + return res.data; + }); + } +} diff --git a/packages/client/src/user/preferences/index.ts b/packages/client/src/user/preferences/index.ts new file mode 100644 index 0000000..a17a3da --- /dev/null +++ b/packages/client/src/user/preferences/index.ts @@ -0,0 +1 @@ +export * from './preferences'; diff --git a/packages/client/src/user/preferences/preferences.test.ts b/packages/client/src/user/preferences/preferences.test.ts new file mode 100644 index 0000000..d6a82df --- /dev/null +++ b/packages/client/src/user/preferences/preferences.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { Tsky } from '~/index'; + +const TEST_CREDENTIALS = { + alice: { + handle: 'alice.tsky.dev', + did: 'did:plc:jguhdmnjclquqf5lsvkyxqy3', + password: 'alice_and_bob', + }, + bob: { + handle: 'bob.tsky.dev', + did: 'did:plc:2ig7akkyfq256j42uxvc4g2h', + password: 'alice_and_bob', + }, +}; + +async function getAliceTsky() { + const tsky = new Tsky(); + + await tsky.auth.login( + TEST_CREDENTIALS.alice.handle, + TEST_CREDENTIALS.alice.password, + ); + + return tsky; +} + +describe('preferences', () => { + it('.get()', async () => { + const tsky = await getAliceTsky(); + const preferences = await tsky.user.preferences.get(); + + expect(preferences).toBeDefined(); + }); + + it('.set()', async () => { + const tsky = await getAliceTsky(); + + const payload = { + $type: 'app.bsky.actor.defs.adultContentPref', + enabled: false, + }; + + await tsky.user.preferences.set([payload]); + + const preferences = await tsky.user.preferences.get(); + + expect(preferences).toBeDefined(); + + const pref = preferences.find((p) => p.$type === payload.$type); + + expect(pref).toBeDefined(); + expect(pref).toHaveProperty('enabled'); + + // @ts-ignore + expect(pref.enabled).toBe(payload.enabled); + }); +}); diff --git a/packages/client/src/user/preferences/preferences.ts b/packages/client/src/user/preferences/preferences.ts new file mode 100644 index 0000000..652614b --- /dev/null +++ b/packages/client/src/user/preferences/preferences.ts @@ -0,0 +1,29 @@ +import type { AppBskyActorPutPreferences } from '@tsky/lexicons'; +import type { Client } from '~/tsky/client'; +import type { RPCOptions } from '~/types'; + +export class Preferences { + constructor(private client: Client) {} + + /** + * Get private preferences attached to the current account. Expected use is synchronization between multiple devices, and import/export during account migration. Requires auth. + */ + async get(options: RPCOptions = {}) { + const res = await this.client.get('app.bsky.actor.getPreferences', options); + + return res.data.preferences; + } + + /** + * Set the private preferences attached to the account. + */ + async set( + preferences: AppBskyActorPutPreferences.Input['preferences'], + options: RPCOptions = {}, + ) { + await this.client.call('app.bsky.actor.putPreferences', { + data: { preferences }, + ...options, + }); + } +} diff --git a/packages/client/src/user/profile.test.ts b/packages/client/src/user/profile.test.ts new file mode 100644 index 0000000..3d24b53 --- /dev/null +++ b/packages/client/src/user/profile.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { Tsky } from '~/index'; + +const TEST_CREDENTIALS = { + alice: { + handle: 'alice.tsky.dev', + did: 'did:plc:jguhdmnjclquqf5lsvkyxqy3', + password: 'alice_and_bob', + }, + bob: { + handle: 'bob.tsky.dev', + did: 'did:plc:2ig7akkyfq256j42uxvc4g2h', + password: 'alice_and_bob', + }, +}; + +describe('profile', async () => { + const tsky = new Tsky(); + + await tsky.auth.login( + TEST_CREDENTIALS.alice.handle, + TEST_CREDENTIALS.alice.password, + ); + + it("Getting alice's profile", async () => { + const profile = await tsky.user.profile(); + + expect(profile).toBeDefined(); + expect(profile.handle).toBe(TEST_CREDENTIALS.alice.handle); + }); + + it("Switching to bob's profile", async () => { + await tsky.auth.login( + TEST_CREDENTIALS.bob.handle, + TEST_CREDENTIALS.bob.password, + ); + }); + + it("Getting bob's profile", async () => { + const profile = await tsky.user.profile(); + + expect(profile).toBeDefined(); + expect(profile.handle).toBe(TEST_CREDENTIALS.bob.handle); + }); +}); diff --git a/packages/client/src/user/suggestion/index.ts b/packages/client/src/user/suggestion/index.ts new file mode 100644 index 0000000..33fe965 --- /dev/null +++ b/packages/client/src/user/suggestion/index.ts @@ -0,0 +1 @@ +export * from './suggestion'; diff --git a/packages/client/src/user/suggestion/suggestion.ts b/packages/client/src/user/suggestion/suggestion.ts new file mode 100644 index 0000000..c6af88c --- /dev/null +++ b/packages/client/src/user/suggestion/suggestion.ts @@ -0,0 +1,50 @@ +import type { Client } from '~/tsky/client'; +import type { RPCOptions } from '~/types'; +import { Paginator } from '~/utils'; + +export class Suggestion { + constructor(private client: Client) {} + + /** + * Get a list of suggested actors. Expected use is discovery of accounts to follow during new account onboarding. + */ + follow(limit?: number, options?: RPCOptions) { + return Paginator.init(async (cursor) => { + const res = await this.client.get('app.bsky.actor.getSuggestions', { + params: { + cursor, + limit, + }, + ...options, + }); + + return res.data; + }); + } + + /** + * Enumerates follows similar to a given account (actor). Expected use is to recommend additional accounts immediately after following one account. + */ + afterFollowing(actor: string, options?: RPCOptions) { + return this.client.get('app.bsky.graph.getSuggestedFollowsByActor', { + params: { + actor, + }, + ...options, + }); + } + + /** + * Get a list of suggested feeds (feed generators) for the requesting account. + */ + feeds(limit?: number, options?: RPCOptions) { + return Paginator.init(async (cursor) => { + const res = await this.client.get('app.bsky.feed.getSuggestedFeeds', { + params: { cursor, limit }, + ...options, + }); + + return res.data; + }); + } +} diff --git a/packages/client/src/user/unmute/index.ts b/packages/client/src/user/unmute/index.ts new file mode 100644 index 0000000..42ac0a4 --- /dev/null +++ b/packages/client/src/user/unmute/index.ts @@ -0,0 +1 @@ +export * from './unmute'; diff --git a/packages/client/src/user/unmute/unmute.ts b/packages/client/src/user/unmute/unmute.ts new file mode 100644 index 0000000..c100a66 --- /dev/null +++ b/packages/client/src/user/unmute/unmute.ts @@ -0,0 +1,37 @@ +import type { Client } from '~/tsky/client'; +import type { RPCOptions } from '~/types'; + +export class Unmute { + constructor(private client: Client) {} + + /** + * Unmutes the specified account. + */ + actor(identifier: string, options: RPCOptions = {}) { + return this.client.call('app.bsky.graph.unmuteActor', { + data: { actor: identifier }, + ...options, + }); + } + + /** + * Unmutes the specified thread. + */ + thread(identifier: string, options: RPCOptions = {}) { + return this.client.call('app.bsky.graph.unmuteThread', { + data: { root: identifier }, + ...options, + }); + } + + /** + * Unmute an entire list (specified by AT-URI) of actors. This removes the mute relationship for all actors + * on the specified list. + */ + actorList(identifier: string, options: RPCOptions = {}) { + return this.client.call('app.bsky.graph.unmuteActorList', { + data: { list: identifier }, + ...options, + }); + } +} diff --git a/packages/client/src/utils/index.ts b/packages/client/src/utils/index.ts new file mode 100644 index 0000000..544dc7f --- /dev/null +++ b/packages/client/src/utils/index.ts @@ -0,0 +1 @@ +export * from './paginator'; diff --git a/packages/client/src/tsky/paginator.ts b/packages/client/src/utils/paginator.ts similarity index 100% rename from packages/client/src/tsky/paginator.ts rename to packages/client/src/utils/paginator.ts diff --git a/packages/client/src/video/index.ts b/packages/client/src/video/index.ts new file mode 100644 index 0000000..777af2d --- /dev/null +++ b/packages/client/src/video/index.ts @@ -0,0 +1 @@ +export * from './video'; diff --git a/packages/client/src/video/video.ts b/packages/client/src/video/video.ts new file mode 100644 index 0000000..96af382 --- /dev/null +++ b/packages/client/src/video/video.ts @@ -0,0 +1,87 @@ +import type { AppBskyVideoDefs, AppBskyVideoUploadVideo } from '@tsky/lexicons'; +import type { Client } from '~/tsky/client'; +import type { RPCOptions } from '~/types'; + +export class Video { + constructor(private client: Client) {} + + /** + * Get video upload limits for the authenticated user. + */ + async limit(options: RPCOptions = {}) { + const res = await this.client.get( + 'app.bsky.video.getUploadLimits', + options, + ); + + return res.data; + } + + /** + * Get status details for a video processing job. + */ + async status(jobId: string, options?: RPCOptions) { + const res = await this.client.get('app.bsky.video.getJobStatus', { + params: { jobId }, + ...options, + }); + + return new JobStatus(this.client, res.data.jobStatus); + } + + /** + * Upload a video to be processed then stored on the PDS. + */ + async upload(data: AppBskyVideoUploadVideo.Input, options?: RPCOptions) { + const res = await this.client.call('app.bsky.video.uploadVideo', { + data, + ...options, + }); + + return new JobStatus(this.client, res.data.jobStatus); + } +} + +class JobStatus { + jobId: string; + did: string; + /** The state of the video processing job. All values not listed as a known value indicate that the job is in process. */ + state: 'JOB_STATE_COMPLETED' | 'JOB_STATE_FAILED' | (string & {}); + /** Progress within the current processing state. */ + progress?: number; + blob?: AppBskyVideoDefs.JobStatus['blob']; + error?: string; + message?: string; + + constructor( + private client: Client, + data: AppBskyVideoDefs.JobStatus, + ) { + this.jobId = data.jobId; + this.did = data.did; + + this.state = data.state; + + this.progress = data.progress; + this.blob = data.blob; + this.error = data.error; + this.message = data.message; + } + + /** + * Update status details for a video processing job. + */ + async refresh(options?: RPCOptions) { + const res = await this.client.get('app.bsky.video.getJobStatus', { + params: { jobId: this.jobId }, + ...options, + }); + + this.state = res.data.jobStatus.state; + + this.progress = res.data.jobStatus.progress; + this.blob = res.data.jobStatus.blob; + this.error = res.data.jobStatus.error; + this.message = res.data.jobStatus.message; + } +} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 9d459d6..1191e37 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -9,7 +9,8 @@ "module": "ESNext", "moduleResolution": "bundler", "paths": { - "~/*": ["src/*"] + "~/*": ["src/*"], + "@tsky/lexicons": ["../lexicons/index.ts"], }, "resolveJsonModule": true, "allowJs": true, diff --git a/packages/lexicons/src/lib/lexicons.ts b/packages/lexicons/src/lib/lexicons.ts index b4d9979..e1a8d5d 100644 --- a/packages/lexicons/src/lib/lexicons.ts +++ b/packages/lexicons/src/lib/lexicons.ts @@ -5,9 +5,9 @@ * @module * Contains type declarations for Bluesky lexicons * @generated - * Generated on: 2025-01-19T08:09:58.687Z + * Generated on: 2025-01-21T16:36:07.633Z * Version: main - * Source: https://github.com/bluesky-social/atproto/tree/cbf17066f314fbc7f2e943127ee4a9f589f8bec2/lexicons + * Source: https://github.com/bluesky-social/atproto/tree/ee9779d07405d991b6be1b1780dae7828ff9d619/lexicons */ /** Base type with optional type field */ @@ -4279,6 +4279,19 @@ export declare namespace ToolsOzoneModerationDefs { reactivatedAt?: string; updatedAt?: string; } + /** Statistics about a particular account subject */ + interface AccountStats extends TypedBase { + /** Total number of appeals against a moderation action on the account */ + appealCount?: number; + /** Number of times the account was escalated */ + escalateCount?: number; + /** Total number of reports on the account */ + reportCount?: number; + /** Number of times the account was suspended */ + suspendCount?: number; + /** Number of times the account was taken down */ + takedownCount?: number; + } interface BlobView extends TypedBase { cid: At.CID; createdAt: string; @@ -4474,6 +4487,25 @@ export declare namespace ToolsOzoneModerationDefs { deletedAt?: string; updatedAt?: string; } + /** Statistics about a set of record subject items */ + interface RecordsStats extends TypedBase { + /** Number of items that were appealed at least once */ + appealedCount?: number; + /** Number of items that were escalated at least once */ + escalatedCount?: number; + /** Number of item currently in "reviewOpen" or "reviewEscalated" state */ + pendingCount?: number; + /** Number of item currently in "reviewNone" or "reviewClosed" state */ + processedCount?: number; + /** Number of items that were reported at least once */ + reportedCount?: number; + /** Total number of item in the set */ + subjectCount?: number; + /** Number of item currently taken down */ + takendownCount?: number; + /** Cumulative sum of the number of reports on the items in the set */ + totalReports?: number; + } interface RecordView extends TypedBase { blobCids: At.CID[]; cid: At.CID; @@ -4548,6 +4580,8 @@ export declare namespace ToolsOzoneModerationDefs { >; /** Timestamp referencing when the last update was made to the moderation status of the subject */ updatedAt: string; + /** Statistics related to the account subject */ + accountStats?: AccountStats; /** True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. */ appealed?: boolean; /** Sticky comment on the subject. */ @@ -4560,6 +4594,8 @@ export declare namespace ToolsOzoneModerationDefs { lastReviewedBy?: At.DID; muteReportingUntil?: string; muteUntil?: string; + /** Statistics related to the record subjects authored by the subject's account */ + recordsStats?: RecordsStats; subjectBlobCids?: At.CID[]; subjectRepoHandle?: string; suspendUntil?: string; @@ -4767,6 +4803,12 @@ export declare namespace ToolsOzoneModerationQueryStatuses { * \@default 50 */ limit?: number; + /** If specified, only subjects that belong to an account that has at least this many suspensions will be returned. */ + minAccountSuspendCount?: number; + /** If specified, only subjects that belong to an account that has at least this many reported records will be returned. */ + minReportedRecordsCount?: number; + /** If specified, only subjects that belong to an account that has at least this many taken down records will be returned. */ + minTakendownRecordsCount?: number; /** When set to true, only muted subjects and reporters will be returned. */ onlyMuted?: boolean; /** Number of queues being used by moderators. Subjects will be split among all queues. */ @@ -4788,7 +4830,11 @@ export declare namespace ToolsOzoneModerationQueryStatuses { /** \@default "desc" */ sortDirection?: "asc" | "desc"; /** \@default "lastReportedAt" */ - sortField?: "lastReviewedAt" | "lastReportedAt"; + sortField?: + | "lastReviewedAt" + | "lastReportedAt" + | "reportedRecordsCount" + | "takendownRecordsCount"; /** The subject to get the status for. */ subject?: string; /** If specified, subjects of the given type (account or record) will be returned. When this is set to 'account' the 'collections' parameter will be ignored. When includeAllUserRecords or subject is set, this will be ignored. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7a6cf2..416b6f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,10 +26,10 @@ importers: '@atcute/client': specifier: ^2.0.6 version: 2.0.6 + devDependencies: '@tsky/lexicons': specifier: workspace:* version: link:../lexicons - devDependencies: globals: specifier: ^15.12.0 version: 15.12.0