diff --git a/docs/guide/features.md b/docs/guide/features.md index 0f961b6..69b486c 100644 --- a/docs/guide/features.md +++ b/docs/guide/features.md @@ -12,9 +12,9 @@ Trotsky is currently limited to the following features: **StepActorLikes** | :white_check_mark: | Get an actor's likes. | ```Trotsky.init(agent).actor('bsky.app').likes().each()``` **StepActorLists** | :white_check_mark: | Get an actor's lists. | ```Trotsky.init(agent).actor('bsky.app').lists().each()``` **StepActorMute** | :white_check_mark: | Mute an actor. | ```Trotsky.init(agent).actor('bsky.app').mute()``` - **StepActorPosts** | :white_check_mark: | Get an actor's posts | ```Trotsky.init(agent).actor('bsky.app').posts().each()``` - **StepActors** | :white_check_mark: | Get a list of actors by their DIDs or handles. | ```Trotsky.init(agent).actors(['bsky.app', 'trotsky.pirhoo.com']).each()``` - **StepActorStarterPacks** | :x: | Get an actor starter packs. | + **StepActorPosts** | :white_check_mark: | Get an actor's posts | ```Trotsky.init(agent).actor('bsky.app').posts().each()``` + **StepActors** | :white_check_mark: | Get a list of actors by their DIDs or handles. | ```Trotsky.init(agent).actors(['bsky.app', 'trotsky.pirhoo.com']).each()``` + **StepActorStarterPacks** | :white_check_mark: | Get an actor's starter packs. | ```Trotsky.init(agent).actor('bsky.app').starterPacks().each()``` **StepActorStreamPosts** | :test_tube: | Stream an actor's posts. | ```Trotsky.init(agent).actor('bsky.app').streamPosts().each()``` **StepActorUnblock** | :white_check_mark: | Unblock an actor. | ```Trotsky.init(agent).actor('bsky.app').unblock()``` **StepActorUnfollow** | :white_check_mark: | Unfollow an actor. | ```Trotsky.init(agent).actor('bsky.app').unfollow()``` @@ -29,10 +29,10 @@ Trotsky is currently limited to the following features: **StepPostReply** | :white_check_mark: | Reply to a post. | ```Trotsky.init(agent).post("at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3l6oveex3ii2l").reply({ text: "Well done!" })``` **StepPostRepost** | :white_check_mark: | Repost a post. | ```Trotsky.init(agent).post("at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3l6oveex3ii2l").repost()``` **StepPosts** | :white_check_mark: | Get a list of post by their URIs. | ```Trotsky.init(agent).posts(["at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3l6oveex3ii2l"]).each()``` - **StepSearchPosts** | :white_check_mark: | Search posts. | ```Trotsky.init(agent).searchPosts({ q: "Mapo Tofu" }).each()``` - **StepSearchStarterPacks** | :x: | Search starter packs. | - **StepStarterPack** | :x: | Get a start pack by its URI. | - **StepStarterPacks** | :x: | Get a list of starter packs by their URIs. | + **StepSearchPosts** | :white_check_mark: | Search posts. | ```Trotsky.init(agent).searchPosts({ q: "Mapo Tofu" }).each()``` + **StepSearchStarterPacks** | :white_check_mark: | Search starter packs. | ```Trotsky.init(agent).searchStarterPacks({ q: "tech" }).each()``` + **StepStarterPack** | :white_check_mark: | Get a starter pack by its URI. | ```Trotsky.init(agent).starterPack("at://did:plc:example/app.bsky.graph.starterpack/packid")``` + **StepStarterPacks** | :white_check_mark: | Get a list of starter packs by their URIs. | ```Trotsky.init(agent).starterPacks([uri1, uri2]).each()``` **StepStreamPosts** | :test_tube: | Use the firehose to stream posts. | ```Trotsky.init(agent).streamPosts().each()``` **StepTimeline** | :x: | Get the timeline. | diff --git a/lib/core/StepActorStarterPacks.ts b/lib/core/StepActorStarterPacks.ts new file mode 100644 index 0000000..efc594d --- /dev/null +++ b/lib/core/StepActorStarterPacks.ts @@ -0,0 +1,90 @@ +import type { AppBskyGraphGetActorStarterPacks } from "@atproto/api" + +import { StepStarterPacks, type StepStarterPacksOutput } from "./StepStarterPacks" +import type { StepActor } from "./StepActor" +import type { StepActorOutput } from "./StepActor" + +/** + * Type representing the output of the starter packs retrieved by {@link StepActorStarterPacks}. + * @public + */ +export type StepActorStarterPacksOutput = StepStarterPacksOutput + +/** + * Type representing the query parameters for retrieving actor starter packs. + * @public + */ +export type StepActorStarterPacksQueryParams = AppBskyGraphGetActorStarterPacks.QueryParams + +/** + * Type representing the cursor for paginated queries. + * @public + */ +export type StepActorStarterPacksQueryParamsCursor = StepActorStarterPacksQueryParams["cursor"] | undefined + +/** + * Represents a step for retrieving an actor's starter packs using the Bluesky API. + * Supports paginated retrieval of starter packs. + * + * @typeParam P - Type of the parent step, extending {@link StepActor}. + * @typeParam C - Type of the context object, extending {@link StepActorOutput}. + * @typeParam O - Type of the output object, extending {@link StepActorStarterPacksOutput}. + * + * @example + * Get an actor's starter packs: + * ```ts + * await Trotsky.init(agent) + * .actor("alice.bsky.social") + * .starterPacks() + * .each() + * .tap((step) => { + * console.log(`Pack: ${step.context.uri}`) + * console.log(`Creator: @${step.context.creator.handle}`) + * console.log(`Members: ${step.context.listItemCount || 0}`) + * }) + * .run() + * ``` + * + * @example + * Follow members from an actor's popular starter packs: + * ```ts + * await Trotsky.init(agent) + * .actor("bob.bsky.social") + * .starterPacks() + * .each() + * .when((step) => (step?.context?.joinedAllTimeCount || 0) > 50) + * .tap((step) => { + * console.log(`Popular pack: ${step.context.uri}`) + * }) + * .run() + * ``` + * + * @public + */ +export class StepActorStarterPacks

extends StepStarterPacks { + + /** + * Applies pagination to retrieve starter packs and sets the output. + * Fetches paginated results using the agent and appends them to the output. + */ + async applyPagination () { + this.output = await this.paginate("starterPacks", (cursor) => { + return this.agent.app.bsky.graph.getActorStarterPacks(this.queryParams(cursor)) + }) + } + + /** + * Generates query parameters for retrieving starter packs, including the optional cursor. + * @param cursor - The cursor for paginated queries. + * @returns The query parameters for retrieving starter packs. + * @throws + * Error if no context is found. + */ + queryParams (cursor: StepActorStarterPacksQueryParamsCursor): StepActorStarterPacksQueryParams { + if (!this.context) { + throw new Error("No context found for StepActorStarterPacks") + } + + return { "actor": this.context.did, cursor } + } +} diff --git a/lib/core/StepActorStreamPosts.ts b/lib/core/StepActorStreamPosts.ts index 85b64a1..bbe211e 100644 --- a/lib/core/StepActorStreamPosts.ts +++ b/lib/core/StepActorStreamPosts.ts @@ -1,4 +1,4 @@ -import { type PostRecord } from "@atproto/api" +import { type AppBskyFeedPostRecord } from "@atproto/api" import { StepStreamPosts, type StepActorOutput } from "../trotsky" import { buildEventEmitter, JetstreamMessageCommit } from "./utils/jetstream" @@ -7,7 +7,7 @@ import { buildEventEmitter, JetstreamMessageCommit } from "./utils/jetstream" * Typically represents the streamed output of a single post event. * @public */ -export type StepActorStreamPostsOutput = JetstreamMessageCommit & { "record": Partial } +export type StepActorStreamPostsOutput = JetstreamMessageCommit & { "record": Partial } /** * @experimental diff --git a/lib/core/StepSearchStarterPacks.ts b/lib/core/StepSearchStarterPacks.ts new file mode 100644 index 0000000..13ea457 --- /dev/null +++ b/lib/core/StepSearchStarterPacks.ts @@ -0,0 +1,120 @@ +import type { AppBskyGraphSearchStarterPacks, AtpAgent } from "@atproto/api" + +import { StepStarterPacks } from "../trotsky" + +/** + * Represents the output of a search starter packs step, consisting of an array of starter packs. + * @public + */ +export type StepSearchStarterPacksOutput = AppBskyGraphSearchStarterPacks.OutputSchema["starterPacks"] + +/** + * Represents the query parameters for searching starter packs. + * @public + */ +export type StepSearchStarterPacksQueryParams = AppBskyGraphSearchStarterPacks.QueryParams + +/** + * Represents the cursor for paginating through search starter pack results. + * @public + */ +export type StepSearchStarterPacksQueryParamsCursor = StepSearchStarterPacksQueryParams["cursor"] | undefined + +/** + * Represents a step for searching starter packs on Bluesky, with support for pagination. + * + * @remarks + * Note: The `searchStarterPacks` API endpoint may not be available on all Bluesky servers. + * As of November 2025, this API is available in test environments but not yet deployed to + * the main Bluesky network. When the API is not available, it will throw an XRPCNotSupported error. + * + * @typeParam P - The parent type of this step. + * @typeParam C - The child context type, defaulting to `null`. + * @typeParam O - The output type, defaulting to {@link StepSearchStarterPacksOutput}. + * + * @example + * Search for starter packs and display them: + * ```ts + * await Trotsky.init(agent) + * .searchStarterPacks({ q: "typescript" }) + * .take(10) + * .each() + * .tap((step) => { + * console.log(`Pack URI: ${step.context.uri}`) + * console.log(`Creator: @${step.context.creator.handle}`) + * console.log(`Members: ${step.context.listItemCount || 0}`) + * }) + * .run() + * ``` + * + * @example + * Filter starter packs by join count: + * ```ts + * await Trotsky.init(agent) + * .searchStarterPacks({ q: "tech" }) + * .take(20) + * .each() + * .when((step) => (step?.context?.joinedAllTimeCount || 0) > 100) + * .tap((step) => { + * console.log(`Popular pack: ${step.context.uri}`) + * }) + * .run() + * ``` + * + * @public + */ +export class StepSearchStarterPacks extends StepStarterPacks { + + /** + * The initial query parameters for the search. + */ + _queryParams: StepSearchStarterPacksQueryParams + + /** + * Initializes the StepSearchStarterPacks instance with the given agent, parent, and query parameters. + * + * @param agent - The AT protocol agent used for API calls. + * @param parent - The parent step in the chain. + * @param queryParams - The initial query parameters for the search. + */ + constructor (agent: AtpAgent, parent: P, queryParams: StepSearchStarterPacksQueryParams) { + super(agent, parent) + this._queryParams = queryParams + } + + /** + * Clones the current step and returns a new instance with the same parameters. + * @param rest - Additional parameters to pass to the cloned step. This is useful for child class overriding the clone. + * @returns A new {@link StepSearchStarterPacks} instance. + */ + override clone (...rest: unknown[]) { + const step = super.clone(...rest) + step._queryParams = this._queryParams + return step + } + + /** + * Applies the pagination logic to retrieve starter packs based on the search parameters. + * Results are stored in the `output` property. + * + * @override + */ + async applyPagination () { + this.output = await this.paginate("starterPacks", (cursor) => { + return this + .agent + .app.bsky.graph + .searchStarterPacks(this.queryParams(cursor)) + }) + } + + /** + * Constructs the query parameters for the current pagination step. + * + * @param cursor - The cursor indicating the current position in the paginated results. + * @returns The query parameters for the search. + */ + queryParams (cursor: StepSearchStarterPacksQueryParamsCursor): StepSearchStarterPacksQueryParams { + return { ...this._queryParams, cursor } + } +} diff --git a/lib/core/StepStarterPack.ts b/lib/core/StepStarterPack.ts new file mode 100644 index 0000000..0038b0b --- /dev/null +++ b/lib/core/StepStarterPack.ts @@ -0,0 +1,111 @@ +import type { AtUri, AtpAgent, AppBskyGraphGetStarterPack, AppBskyGraphDefs } from "@atproto/api" + +import { Step, type StepBuilder } from "../trotsky" +import type { Resolvable } from "./utils/resolvable" +import { resolveValue } from "./utils/resolvable" + +/** + * Defines the query parameters for retrieving a starter pack. + * @public + */ +export type StepStarterPackQueryParams = AppBskyGraphGetStarterPack.QueryParams + +/** + * Represents a starter pack's URI, which can be a string or an {@link AtUri}. + * @public + */ +export type StepStarterPackUri = string | AtUri + +/** + * Represents the output of a retrieved starter pack, including its URI, CID, record data, creator, and associated lists/feeds. + * @public + */ +export type StepStarterPackOutput = AppBskyGraphDefs.StarterPackView + +/** + * Represents a step for retrieving a starter pack by its URI. + * + * @typeParam P - The parent type of this step, defaulting to {@link StepBuilder}. + * @typeParam C - The child context type, defaulting to `null`. + * @typeParam O - The output type, defaulting to {@link StepStarterPackOutput}. + * + * @example + * Get a specific starter pack: + * ```ts + * await Trotsky.init(agent) + * .starterPack("at://did:plc:example/app.bsky.graph.starterpack/packid") + * .tap((step) => { + * console.log(`Creator: ${step.context.creator.handle}`) + * console.log(`List: ${step.context.list?.name}`) + * console.log(`Joined this week: ${step.context.joinedWeekCount}`) + * }) + * .run() + * ``` + * + * @example + * Get starter pack details and display feeds: + * ```ts + * await Trotsky.init(agent) + * .starterPack("at://did:plc:example/app.bsky.graph.starterpack/packid") + * .tap((step) => { + * console.log(`Feeds in pack: ${step.context.feeds?.length || 0}`) + * step.context.feeds?.forEach(feed => { + * console.log(`- ${feed.displayName}`) + * }) + * }) + * .run() + * ``` + * + * @public + */ +export class StepStarterPack

extends Step { + + /** + * The URI of the starter pack to retrieve, which can be resolved dynamically at runtime. + * @internal + */ + _uri: Resolvable + + /** + * Initializes the StepStarterPack instance with the given agent, parent, and URI. + * + * @param agent - The AT protocol agent used for API calls. + * @param parent - The parent step in the chain. + * @param uri - The URI of the starter pack to retrieve, possibly resolvable at runtime. + */ + constructor (agent: AtpAgent, parent: P, uri: Resolvable) { + super(agent, parent) + this._uri = uri + } + + /** + * Clones the current step and returns a new instance with the same parameters. + * @param rest - Additional parameters to pass to the cloned step. This is useful for child class overriding the clone. + * @returns A new {@link StepStarterPack} instance. + */ + override clone (...rest: unknown[]) { + return super.clone(this._uri, ...rest) + } + + /** + * Applies the step logic to retrieve the starter pack and sets the output to the retrieved starter pack's data. + * If no starter pack is found, the output is set to `null`. + * + * @override + */ + async apply () { + const { "data": { starterPack } } = await this.agent.app.bsky.graph.getStarterPack(await this.queryParams()) + this.output = starterPack as O ?? null + } + + /** + * Resolves the query parameters for retrieving the starter pack, including its URI. + * + * @returns A promise that resolves to the query parameters. + */ + async queryParams (): Promise { + const uri = await resolveValue(this, this._uri) + const starterPack = uri.toString?.() ?? uri.toString() + return { starterPack } + } +} diff --git a/lib/core/StepStarterPacks.ts b/lib/core/StepStarterPacks.ts new file mode 100644 index 0000000..9d61367 --- /dev/null +++ b/lib/core/StepStarterPacks.ts @@ -0,0 +1,126 @@ +import type { AtUri, AtpAgent, AppBskyGraphGetStarterPacks, AppBskyGraphDefs } from "@atproto/api" + +import { StepBuilderList, StepBuilderListIterator, StepStarterPacksEntry, type StepBuilder } from "../trotsky" +import { Resolvable, resolveValue } from "./utils/resolvable" + +/** + * Type for the parameter passed to the {@link StepStarterPacks} class. + * Represents the URIs of several starter packs. + * @public + */ +export type StepStarterPacksUris = (string | AtUri)[] + +/** + * Defines the query parameters for retrieving multiple starter packs. + * @public + */ +export type StepStarterPacksQueryParams = AppBskyGraphGetStarterPacks.QueryParams + +/** + * Represents the output of a starter packs step, consisting of an array of starter pack views. + * @public + */ +export type StepStarterPacksOutput = AppBskyGraphDefs.StarterPackViewBasic[] + +/** + * Abstract class representing a list of starter packs to process. + * + * @typeParam P - The parent type of this step, defaulting to {@link StepBuilder}. + * @typeParam C - The child context type, defaulting to `null`. + * @typeParam O - The output type, defaulting to {@link StepStarterPacksOutput}. + * + * @example + * Get multiple starter packs by their URIs: + * ```ts + * const packUris = [ + * "at://did:plc:example1/app.bsky.graph.starterpack/pack1", + * "at://did:plc:example2/app.bsky.graph.starterpack/pack2" + * ] + * + * await Trotsky.init(agent) + * .starterPacks(packUris) + * .tap((step) => { + * step.context.forEach(pack => { + * console.log(`${pack.creator.handle}: ${pack.listItemCount} members`) + * }) + * }) + * .run() + * ``` + * + * @example + * Iterate through multiple starter packs: + * ```ts + * await Trotsky.init(agent) + * .starterPacks([uri1, uri2, uri3]) + * .each() + * .tap((step) => { + * const pack = step.context + * console.log(`Pack by @${pack.creator.handle}`) + * console.log(`Joined: ${pack.joinedAllTimeCount || 0}`) + * }) + * .run() + * ``` + * + * @public + */ +export class StepStarterPacks

extends StepBuilderList { + + /** + * Holds the list of steps to be executed for each starter pack entry. + */ + _steps: StepStarterPacksEntry[] = [] + + /** + * The URIs of the starter packs to retrieve, which can be resolved dynamically at runtime. + */ + _uris: Resolvable + + /** + * Initializes the StepStarterPacks instance with the given agent, parent, and URIs. + * + * @param agent - The AT protocol agent used for API calls. + * @param parent - The parent step in the chain. + * @param uris - The URIs of the starter packs to retrieve, possibly resolvable at runtime. + */ + constructor (agent: AtpAgent, parent: P, uris: Resolvable = []) { + super(agent, parent) + this._uris = uris + } + + /** + * Clones the current step and returns a new instance with the same parameters. + * @param rest - Additional parameters to pass to the cloned step. This is useful for child class overriding the clone. + * @returns A new {@link StepStarterPacks} instance. + */ + override clone (...rest: unknown[]) { + return super.clone(this._uris, ...rest) + } + + /** + * Appends a new starter pack entry step to the current list and returns it. + * + * @param iterator - Optional iterator function to be executed for each item in the list. + * @returns The newly appended {@link StepStarterPacksEntry} instance. + */ + each (iterator?: StepBuilderListIterator): StepStarterPacksEntry { + return this.withIterator(iterator).append(StepStarterPacksEntry) + } + + /** + * Applies the step by resolving the URIs parameter and fetching the starter packs. + * Sets the starter packs data as the output of this step. + * @returns A promise that resolves when the step is complete. + */ + async applyPagination (): Promise { + const uris = (await resolveValue(this, this._uris)).map(uri => uri.toString()) + + // If no URIs provided, return empty array + if (uris.length === 0) { + this.output = [] as unknown as O + return + } + + const { data } = await this.agent.app.bsky.graph.getStarterPacks({ uris }) + this.output = data.starterPacks as O + } +} diff --git a/lib/core/StepStarterPacksEntry.ts b/lib/core/StepStarterPacksEntry.ts new file mode 100644 index 0000000..17d841a --- /dev/null +++ b/lib/core/StepStarterPacksEntry.ts @@ -0,0 +1,52 @@ +import type { StepStarterPacks, StepStarterPacksOutput } from "../trotsky" +import { StepBuilderListEntry } from "./StepBuilderListEntry" + +/** + * Represents an individual entry step within a {@link StepStarterPacks} list. + * Provides context for each starter pack in the iteration. + * + * @typeParam P - The parent type of this step, defaulting to {@link StepStarterPacks}. + * @typeParam C - The context type, defaulting to an element of {@link StepStarterPacksOutput}. + * @typeParam O - The output type, defaulting to `unknown`. + * + * @example + * Iterate through starter packs and log details: + * ```ts + * await Trotsky.init(agent) + * .starterPacks([uri1, uri2]) + * .each() + * .tap((step) => { + * console.log(`Creator: @${step.context.creator.handle}`) + * console.log(`Members: ${step.context.listItemCount}`) + * }) + * .run() + * ``` + * + * @example + * Filter starter packs by join count: + * ```ts + * await Trotsky.init(agent) + * .starterPacks(packUris) + * .each() + * .when((step) => (step.context.joinedAllTimeCount || 0) > 100) + * .tap((step) => { + * console.log(`Popular pack: ${step.context.uri}`) + * }) + * .run() + * ``` + * + * @public + */ +export class StepStarterPacksEntry< + P = StepStarterPacks, + C = StepStarterPacksOutput[number], + O = unknown +> extends StepBuilderListEntry { + + /** + * Applies the step's logic but does nothing by default. This method is + * usually overridden by child classes but will not throw an error if not. + * @override + */ + async apply (): Promise { } +} diff --git a/lib/core/Trotsky.ts b/lib/core/Trotsky.ts index e05629b..c38dec6 100644 --- a/lib/core/Trotsky.ts +++ b/lib/core/Trotsky.ts @@ -8,19 +8,25 @@ import type { StepCreatePostParams } from "./StepCreatePost" import type { StepWhenPredicate } from "./StepWhen" import type { StepTapInterceptor } from "./StepTap" import type { Resolvable } from "./utils/resolvable" +import type { StepStarterPackUri } from "./StepStarterPack" +import type { StepStarterPacksUris } from "./StepStarterPacks" +import type { StepSearchStarterPacksQueryParams } from "./StepSearchStarterPacks" -import { - StepActor, +import { + StepActor, StepActors, - StepWait, + StepWait, StepPost, - StepCreatePost, - StepList, + StepCreatePost, + StepList, StepListUri, - StepSearchPosts, - StepStreamPosts, - StepTap, - StepWhen, + StepStarterPack, + StepStarterPacks, + StepSearchPosts, + StepSearchStarterPacks, + StepStreamPosts, + StepTap, + StepWhen, StepBuilder, StepActorsParam, StepPosts, @@ -91,6 +97,24 @@ export class Trotsky extends StepBuilder { return this.append(StepList, uri) } + /** + * Adds a {@link StepStarterPack} step. + * @param uri - The starter pack URI. + * @returns The new {@link StepStarterPack} instance. + */ + starterPack (uri: Resolvable) { + return this.append(StepStarterPack, uri) + } + + /** + * Adds a {@link StepStarterPacks} step. + * @param uris - The starter pack URIs. + * @returns The new {@link StepStarterPacks} instance. + */ + starterPacks (uris: Resolvable): StepStarterPacks { + return this.append(StepStarterPacks, uris) + } + /** * Adds a {@link StepSearchPosts} step. * @param queryParams - Search parameters. @@ -100,6 +124,15 @@ export class Trotsky extends StepBuilder { return this.append(StepSearchPosts, queryParams) } + /** + * Adds a {@link StepSearchStarterPacks} step. + * @param queryParams - Search parameters. + * @returns The new {@link StepSearchStarterPacks} instance. + */ + searchStarterPacks (queryParams: StepSearchStarterPacksQueryParams): StepSearchStarterPacks { + return this.append(StepSearchStarterPacks, queryParams) + } + /** * Adds a {@link StepStreamPosts} step. * @returns The new {@link StepStreamPosts} instance. diff --git a/lib/core/mixins/ActorMixins.ts b/lib/core/mixins/ActorMixins.ts index b8adc1e..cd1bf71 100644 --- a/lib/core/mixins/ActorMixins.ts +++ b/lib/core/mixins/ActorMixins.ts @@ -8,6 +8,7 @@ import { StepActorLikes, StepActorMute, StepActorPosts, + StepActorStarterPacks, StepActorUnblock, StepActorUnfollow, StepActorUnmute, @@ -64,16 +65,25 @@ export abstract class ActorMixins extends Step { /** * Appends a step to fetch the posts of the current actor. - * + * * @returns The appended {@link StepActorPosts} instance. */ posts (): StepActorPosts { return this.append(StepActorPosts) } + /** + * Appends a step to fetch the starter packs of the current actor. + * + * @returns The appended {@link StepActorStarterPacks} instance. + */ + starterPacks (): StepActorStarterPacks { + return this.append(StepActorStarterPacks) + } + /** * Appends a step to stream the posts of the current actor. - * + * * @typeParam T - The type of the stream posts step, defaulting to {@link StepActorStreamPosts}. * @returns The appended {@link StepActorStreamPosts} instance. */ diff --git a/lib/core/utils/jetstream.ts b/lib/core/utils/jetstream.ts index 21f927d..8a7e848 100644 --- a/lib/core/utils/jetstream.ts +++ b/lib/core/utils/jetstream.ts @@ -1,4 +1,4 @@ -import type { Did, FollowRecord, LikeRecord, PostRecord } from "@atproto/api" +import type { Did, AppBskyGraphFollowRecord, AppBskyFeedLikeRecord, AppBskyFeedPostRecord } from "@atproto/api" import WebSocket from "ws" import EventEmitter from "events" import { decompress } from "@skhaz/zstd" @@ -24,7 +24,7 @@ export interface JetstreamMessageCommit extends JetstreamMessageBase { "collection": string; "rkey": string; "cid": string; - "record"?: Partial | Partial | Partial; + "record"?: Partial | Partial | Partial; }; } diff --git a/lib/trotsky.ts b/lib/trotsky.ts index 03f0e44..d6367d2 100644 --- a/lib/trotsky.ts +++ b/lib/trotsky.ts @@ -80,6 +80,7 @@ export * from "./core/StepActorLikes" export * from "./core/StepActorLists" export * from "./core/StepActorMute" export * from "./core/StepActorPosts" +export * from "./core/StepActorStarterPacks" export * from "./core/StepActorStreamPosts" export * from "./core/StepActorUnblock" export * from "./core/StepActorUnfollow" @@ -97,6 +98,14 @@ export * from "./core/StepPostRepost" export * from "./core/StepList" export * from "./core/StepListMembers" +// Single starter pack +export * from "./core/StepStarterPack" + +// List of starter packs +export * from "./core/StepStarterPacks" +export * from "./core/StepStarterPacksEntry" +export * from "./core/StepSearchStarterPacks" + // Utils export * from "./core/StepTap" export * from "./core/StepWait" diff --git a/package.json b/package.json index a45abb2..ecd9081 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "trotsky", - "version": "0.1.9", + "version": "0.2.1", "main": "dist/trotsky.js", "typings": "dist/trotsky.d.ts", "author": "hello@pirhoo.com", diff --git a/tests/core/StepActorStarterPacks.test.ts b/tests/core/StepActorStarterPacks.test.ts new file mode 100644 index 0000000..8f4107d --- /dev/null +++ b/tests/core/StepActorStarterPacks.test.ts @@ -0,0 +1,178 @@ +import { afterAll, beforeAll, describe, expect, test } from "@jest/globals" +import { TestNetwork, SeedClient, usersSeed } from "@atproto/dev-env" +import { AtpAgent } from "@atproto/api" +import { Trotsky, StepActorStarterPacks } from "../../lib/trotsky" + +describe("StepActorStarterPacks", () => { + let network: TestNetwork + let agent: AtpAgent + let sc: SeedClient + + // accounts + let bob: { "did": string; "handle": string; "password": string } + let alice: { "did": string; "handle": string; "password": string } + let carol: { "did": string; "handle": string; "password": string } + + // starter packs + let starterPack1: { "uri": string; "cid": string } + let starterPack2: { "uri": string; "cid": string } + + beforeAll(async () => { + network = await TestNetwork.create({ + "dbPostgresSchema": "trotsky_step_actor_starter_packs" + }) + + agent = network.bsky.getClient() + sc = network.getSeedClient() + + // Seed users + await usersSeed(sc) + bob = sc.accounts[sc.dids.bob] + alice = sc.accounts[sc.dids.alice] + carol = sc.accounts[sc.dids.carol] + + // Create starter packs for bob + starterPack1 = await sc.createStarterPack(bob.did, "Tech Community", [alice.did]) + starterPack2 = await sc.createStarterPack(bob.did, "JavaScript Enthusiasts", [carol.did]) + + // Create a starter pack for alice (to test filtering) + await sc.createStarterPack(alice.did, "Alice's Pack", [bob.did]) + + await network.processAll() + }, 120e3) + + afterAll(async () => { + await network.close() + }) + + test("should clone properly", () => { + const step = Trotsky.init(agent).actor(bob.handle).starterPacks() + const cloned = step.clone() + expect(cloned).toBeInstanceOf(StepActorStarterPacks) + }) + + test("should get actor's starter packs", async () => { + const packs = await Trotsky.init(agent) + .actor(bob.handle) + .starterPacks() + .runHere() + + expect(packs).toBeInstanceOf(StepActorStarterPacks) + expect(packs.output).toBeInstanceOf(Array) + expect(packs.output.length).toBe(2) + }) + + test("should return starter packs with correct structure", async () => { + const packs = await Trotsky.init(agent) + .actor(bob.handle) + .starterPacks() + .runHere() + + packs.output.forEach(pack => { + expect(pack).toHaveProperty("uri") + expect(pack).toHaveProperty("creator") + expect(pack.creator).toHaveProperty("handle") + expect(pack.creator).toHaveProperty("did") + expect(pack.creator.did).toBe(bob.did) + }) + }) + + test("should verify URIs match created packs", async () => { + const packs = await Trotsky.init(agent) + .actor(bob.handle) + .starterPacks() + .runHere() + + const uris = packs.output.map(p => p.uri) + expect(uris).toContain(starterPack1.uri.toString()) + expect(uris).toContain(starterPack2.uri.toString()) + }) + + test("should iterate through each starter pack", async () => { + const uris: string[] = [] + + await Trotsky.init(agent) + .actor(bob.handle) + .starterPacks() + .each() + .tap((step) => { + if (step?.context?.uri) { + uris.push(step.context.uri) + } + }) + .run() + + expect(uris).toHaveLength(2) + expect(uris).toContain(starterPack1.uri.toString()) + expect(uris).toContain(starterPack2.uri.toString()) + }) + + test("should filter starter packs with when()", async () => { + const filteredPacks: string[] = [] + + await Trotsky.init(agent) + .actor(bob.handle) + .starterPacks() + .each() + .when((step) => step?.context?.uri === starterPack1.uri.toString()) + .tap((step) => { + if (step?.context?.uri) { + filteredPacks.push(step.context.uri) + } + }) + .run() + + expect(filteredPacks).toHaveLength(1) + expect(filteredPacks[0]).toBe(starterPack1.uri.toString()) + }) + + test("should handle pagination with take()", async () => { + const packs = await Trotsky.init(agent) + .actor(bob.handle) + .starterPacks() + .take(1) + .runHere() + + expect(packs.output.length).toBeLessThanOrEqual(1) + }) + + test("should return empty array for actor with no packs", async () => { + const packs = await Trotsky.init(agent) + .actor(carol.handle) + .starterPacks() + .runHere() + + expect(packs.output).toBeInstanceOf(Array) + expect(packs.output).toHaveLength(0) + }) + + test("should return indexed timestamps", async () => { + const packs = await Trotsky.init(agent) + .actor(bob.handle) + .starterPacks() + .runHere() + + packs.output.forEach(pack => { + expect(pack).toHaveProperty("indexedAt") + expect(pack.indexedAt).toBeTruthy() + }) + }) + + test("should work with tap() to process results", async () => { + const result = await Trotsky.init(agent) + .actor(bob.handle) + .starterPacks() + .runHere() + + expect(result.output).toBeInstanceOf(Array) + expect(result.output.length).toBeGreaterThan(0) + }) + + test("should throw error when no context is available", async () => { + const step = new StepActorStarterPacks(agent, null) + + await expect(async () => { + await step.applyPagination() + }).rejects.toThrow("No context found for StepActorStarterPacks") + }) +}) diff --git a/tests/core/StepSearchStarterPacks.test.ts b/tests/core/StepSearchStarterPacks.test.ts new file mode 100644 index 0000000..ddf091f --- /dev/null +++ b/tests/core/StepSearchStarterPacks.test.ts @@ -0,0 +1,158 @@ +import { afterAll, beforeAll, describe, expect, test } from "@jest/globals" +import { TestNetwork, SeedClient, usersSeed } from "@atproto/dev-env" +import { AtpAgent } from "@atproto/api" +import { Trotsky, StepSearchStarterPacks } from "../../lib/trotsky" + +describe("StepSearchStarterPacks", () => { + let network: TestNetwork + let agent: AtpAgent + let sc: SeedClient + + // accounts + let bob: { "did": string; "handle": string; "password": string } + let alice: { "did": string; "handle": string; "password": string } + let carol: { "did": string; "handle": string; "password": string } + + beforeAll(async () => { + network = await TestNetwork.create({ + "dbPostgresSchema": "trotsky_step_search_starter_packs" + }) + + agent = network.bsky.getClient() + sc = network.getSeedClient() + + // Seed users + await usersSeed(sc) + bob = sc.accounts[sc.dids.bob] + alice = sc.accounts[sc.dids.alice] + carol = sc.accounts[sc.dids.carol] + + // Create starter packs with different names for search testing + await sc.createStarterPack(bob.did, "Tech Community", [alice.did]) + await sc.createStarterPack(bob.did, "TypeScript Developers", [carol.did]) + await sc.createStarterPack(alice.did, "JavaScript Enthusiasts", [bob.did]) + + await network.processAll() + }, 120e3) + + afterAll(async () => { + await network.close() + }) + + test("should clone properly", () => { + const step = Trotsky.init(agent).searchStarterPacks({ "q": "tech" }) + const cloned = step.clone() + expect(cloned).toBeInstanceOf(StepSearchStarterPacks) + expect(cloned._queryParams).toEqual(step._queryParams) + }) + + test("should search starter packs by query", async () => { + const packs = await Trotsky.init(agent) + .searchStarterPacks({ "q": "Tech" }) + .runHere() + + expect(packs).toBeInstanceOf(StepSearchStarterPacks) + expect(packs.output).toBeInstanceOf(Array) + expect(packs.output.length).toBeGreaterThan(0) + }) + + test("should return starter packs with correct structure", async () => { + const packs = await Trotsky.init(agent) + .searchStarterPacks({ "q": "Community" }) + .runHere() + + packs.output.forEach(pack => { + expect(pack).toHaveProperty("uri") + expect(pack).toHaveProperty("creator") + expect(pack.creator).toHaveProperty("handle") + expect(pack.creator).toHaveProperty("did") + }) + }) + + test("should support limit parameter", async () => { + const packsWithoutLimit = await Trotsky.init(agent) + .searchStarterPacks({ "q": "Tech" }) + .runHere() + + const packsWithLimit = await Trotsky.init(agent) + .searchStarterPacks({ "q": "Tech", "limit": 1 }) + .runHere() + + // With limit should return same or fewer results + expect(packsWithLimit.output.length).toBeLessThanOrEqual(packsWithoutLimit.output.length) + }) + + test("should iterate through search results with each()", async () => { + const uris: string[] = [] + + await Trotsky.init(agent) + .searchStarterPacks({ "q": "JavaScript" }) + .take(5) + .each() + .tap((step) => { + if (step?.context?.uri) { + uris.push(step.context.uri) + } + }) + .run() + + expect(uris.length).toBeGreaterThan(0) + }) + + test("should filter search results with when()", async () => { + const filteredPacks: string[] = [] + + await Trotsky.init(agent) + .searchStarterPacks({ "q": "TypeScript" }) + .take(10) + .each() + .when((step) => step?.context?.creator.handle === bob.handle) + .tap((step) => { + if (step?.context?.uri) { + filteredPacks.push(step.context.uri) + } + }) + .run() + + // Should only include packs created by bob + expect(filteredPacks.length).toBeGreaterThan(0) + }) + + test("should handle pagination with take()", async () => { + const packs = await Trotsky.init(agent) + .searchStarterPacks({ "q": "Developers" }) + .take(2) + .runHere() + + expect(packs.output.length).toBeLessThanOrEqual(2) + }) + + test("should return empty array for query with no results", async () => { + const packs = await Trotsky.init(agent) + .searchStarterPacks({ "q": "NonExistentQueryThatWillNeverMatch12345" }) + .runHere() + + expect(packs.output).toBeInstanceOf(Array) + expect(packs.output).toHaveLength(0) + }) + + test("should return starter packs with indexed timestamps", async () => { + const packs = await Trotsky.init(agent) + .searchStarterPacks({ "q": "Community" }) + .runHere() + + packs.output.forEach(pack => { + expect(pack).toHaveProperty("indexedAt") + expect(pack.indexedAt).toBeTruthy() + }) + }) + + test("should work with tap() to process results", async () => { + const result = await Trotsky.init(agent) + .searchStarterPacks({ "q": "Tech" }) + .runHere() + + expect(result.output).toBeInstanceOf(Array) + expect(result.output.length).toBeGreaterThan(0) + }) +}) diff --git a/tests/core/StepStarterPack.test.ts b/tests/core/StepStarterPack.test.ts new file mode 100644 index 0000000..c9fda41 --- /dev/null +++ b/tests/core/StepStarterPack.test.ts @@ -0,0 +1,93 @@ +import { afterAll, beforeAll, describe, expect, test } from "@jest/globals" +import { AtpAgent } from "@atproto/api" +import { TestNetwork, SeedClient, usersSeed, RecordRef } from "@atproto/dev-env" + +import { StepStarterPack, Trotsky } from "../../lib/trotsky" + +describe("StepStarterPack", () => { + let network: TestNetwork + let agent: AtpAgent + let sc: SeedClient + let alice: { "did": string; "handle": string; "password": string } + let bob: { "did": string; "handle": string; "password": string } + let carol: { "did": string; "handle": string; "password": string } + let starterPack: RecordRef + + beforeAll(async () => { + network = await TestNetwork.create({ "dbPostgresSchema": "step_starter_pack" }) + agent = network.bsky.getClient() + sc = network.getSeedClient() + // Seed users + await usersSeed(sc) + bob = sc.accounts[sc.dids.bob] + alice = sc.accounts[sc.dids.alice] + carol = sc.accounts[sc.dids.carol] + // Create a starter pack with Alice and Carol as members + starterPack = await sc.createStarterPack(bob.did, "Bob's Starter Pack", [alice.did, carol.did]) + await network.processAll() + }) + + afterAll(async () => { + // For some reason the AppView schema is not being dropped + await network.bsky.db.db.schema.dropSchema("appview_step_starter_pack").cascade().execute() + await network.close() + }) + + test("clones it", () => { + const clone = new StepStarterPack(agent, null, "at://did/repo/rkey").clone() + expect(clone).toBeInstanceOf(StepStarterPack) + expect(clone).toHaveProperty("_uri", "at://did/repo/rkey") + }) + + test("should get a starter pack", async () => { + await expect(Trotsky.init(agent).starterPack(starterPack.uri).run()).resolves.not.toThrow() + }) + + test("should get a starter pack's creator", async () => { + const pack = await Trotsky.init(agent).starterPack(starterPack.uri).runHere() + expect(pack.output).toHaveProperty("creator") + expect(pack.output.creator).toHaveProperty("handle", bob.handle) + }) + + test("should get a starter pack's URI", async () => { + const pack = await Trotsky.init(agent).starterPack(starterPack.uri).runHere() + expect(pack.output).toHaveProperty("uri") + expect(pack.output.uri).toBe(starterPack.uri.toString()) + }) + + test("should get a starter pack with list items", async () => { + const pack = await Trotsky.init(agent).starterPack(starterPack.uri).runHere() + expect(pack.output).toHaveProperty("listItemsSample") + expect(pack.output.listItemsSample).toBeInstanceOf(Array) + }) + + test("should have list items sample with expected members", async () => { + const pack = await Trotsky.init(agent).starterPack(starterPack.uri).runHere() + const handles = pack.output.listItemsSample?.map(item => item.subject.handle) || [] + expect(handles).toContain(alice.handle) + expect(handles).toContain(carol.handle) + }) + + test("should get a starter pack with indexed timestamp", async () => { + const pack = await Trotsky.init(agent).starterPack(starterPack.uri).runHere() + expect(pack.output).toHaveProperty("indexedAt") + expect(typeof pack.output.indexedAt).toBe("string") + }) + + test("should get a starter pack with CID", async () => { + const pack = await Trotsky.init(agent).starterPack(starterPack.uri).runHere() + expect(pack.output).toHaveProperty("cid") + expect(typeof pack.output.cid).toBe("string") + }) + + test("should get a starter pack with record", async () => { + const pack = await Trotsky.init(agent).starterPack(starterPack.uri).runHere() + expect(pack.output).toHaveProperty("record") + expect(typeof pack.output.record).toBe("object") + }) + + test("should handle non-existent starter pack gracefully", async () => { + const fakeUri = "at://did:plc:fake/app.bsky.graph.starterpack/fake" + await expect(Trotsky.init(agent).starterPack(fakeUri).run()).rejects.toThrow() + }) +}) diff --git a/tests/core/StepStarterPacks.test.ts b/tests/core/StepStarterPacks.test.ts new file mode 100644 index 0000000..7e597f4 --- /dev/null +++ b/tests/core/StepStarterPacks.test.ts @@ -0,0 +1,163 @@ +import { afterAll, beforeAll, describe, expect, test } from "@jest/globals" +import { TestNetwork, SeedClient, usersSeed, RecordRef } from "@atproto/dev-env" +import { AtpAgent } from "@atproto/api" +import { Trotsky } from "../../lib/trotsky" + +describe("StepStarterPacks", () => { + let network: TestNetwork + let agent: AtpAgent + let sc: SeedClient + + // accounts + let bob: { "did": string; "handle": string; "password": string } + let alice: { "did": string; "handle": string; "password": string } + let carol: { "did": string; "handle": string; "password": string } + let dan: { "did": string; "handle": string; "password": string } + + // starter packs + let starterPack1: RecordRef + let starterPack2: RecordRef + + beforeAll(async () => { + network = await TestNetwork.create({ + "dbPostgresSchema": "trotsky_step_starter_packs" + }) + + agent = network.bsky.getClient() + sc = network.getSeedClient() + + // Seed users + await usersSeed(sc) + bob = sc.accounts[sc.dids.bob] + alice = sc.accounts[sc.dids.alice] + carol = sc.accounts[sc.dids.carol] + dan = sc.accounts[sc.dids.dan] + + // Create first starter pack with alice and carol + starterPack1 = await sc.createStarterPack(bob.did, "Tech Community", [alice.did, carol.did]) + + // Create second starter pack with dan + starterPack2 = await sc.createStarterPack(bob.did, "Developers", [dan.did]) + + await network.processAll() + }, 120e3) + + afterAll(async () => { + await network.close() + }) + + test("should clone properly", () => { + const step = Trotsky.init(agent).starterPacks([starterPack1.uri, starterPack2.uri]) + const cloned = step.clone() + expect(cloned).toBeInstanceOf(step.constructor) + expect(cloned._uris).toEqual(step._uris) + }) + + test("should get multiple starter packs", async () => { + const packs = await Trotsky.init(agent) + .starterPacks([starterPack1.uri, starterPack2.uri]) + .runHere() + + expect(packs.output).toHaveLength(2) + expect(packs.output[0]).toHaveProperty("uri") + expect(packs.output[1]).toHaveProperty("uri") + }) + + test("should get starter packs with correct URIs", async () => { + const packs = await Trotsky.init(agent) + .starterPacks([starterPack1.uri, starterPack2.uri]) + .runHere() + + const uris = packs.output.map(pack => pack.uri) + expect(uris).toContain(starterPack1.uri.toString()) + expect(uris).toContain(starterPack2.uri.toString()) + }) + + test("should get starter packs with creator information", async () => { + const packs = await Trotsky.init(agent) + .starterPacks([starterPack1.uri, starterPack2.uri]) + .runHere() + + packs.output.forEach(pack => { + expect(pack).toHaveProperty("creator") + expect(pack.creator).toHaveProperty("handle", bob.handle) + expect(pack.creator).toHaveProperty("did", bob.did) + }) + }) + + test("should get starter packs with list item counts", async () => { + const packs = await Trotsky.init(agent) + .starterPacks([starterPack1.uri, starterPack2.uri]) + .runHere() + + const pack1 = packs.output.find(p => p.uri === starterPack1.uri.toString()) + const pack2 = packs.output.find(p => p.uri === starterPack2.uri.toString()) + + // listItemCount may be present if supported by the API + expect(pack1).toBeDefined() + expect(pack2).toBeDefined() + // The count may or may not be populated depending on the test environment + // If available, it should be a non-negative number + expect(pack1?.listItemCount === undefined || pack1.listItemCount >= 0).toBe(true) + expect(pack2?.listItemCount === undefined || pack2.listItemCount >= 0).toBe(true) + }) + + test("should iterate through each starter pack", async () => { + const uris: string[] = [] + + await Trotsky.init(agent) + .starterPacks([starterPack1.uri, starterPack2.uri]) + .each() + .tap((step) => { + uris.push(step?.context?.uri || "") + }) + .run() + + expect(uris).toHaveLength(2) + expect(uris).toContain(starterPack1.uri.toString()) + expect(uris).toContain(starterPack2.uri.toString()) + }) + + test("should filter starter packs with when()", async () => { + const filteredPacks: string[] = [] + + await Trotsky.init(agent) + .starterPacks([starterPack1.uri, starterPack2.uri]) + .each() + .when((step) => step?.context?.uri === starterPack1.uri.toString()) + .tap((step) => { + filteredPacks.push(step?.context?.uri || "") + }) + .run() + + expect(filteredPacks).toHaveLength(1) + expect(filteredPacks[0]).toBe(starterPack1.uri.toString()) + }) + + test("should handle empty array", async () => { + const packs = await Trotsky.init(agent).starterPacks([]).runHere() + expect(packs.output).toHaveLength(0) + }) + + test("should return indexed timestamps", async () => { + const packs = await Trotsky.init(agent) + .starterPacks([starterPack1.uri, starterPack2.uri]) + .runHere() + + packs.output.forEach(pack => { + expect(pack).toHaveProperty("indexedAt") + expect(pack.indexedAt).toBeTruthy() + }) + }) + + test("should iterate with custom iterator function", async () => { + let count = 0 + + await Trotsky.init(agent) + .starterPacks([starterPack1.uri, starterPack2.uri]) + .each(() => count++) + .run() + + expect(count).toBe(2) + }) +})