@mepuka/skygent 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +59 -0
  2. package/index.ts +146 -0
  3. package/package.json +56 -0
  4. package/src/cli/app.ts +75 -0
  5. package/src/cli/config-command.ts +140 -0
  6. package/src/cli/config.ts +91 -0
  7. package/src/cli/derive.ts +205 -0
  8. package/src/cli/doc/annotation.ts +36 -0
  9. package/src/cli/doc/filter.ts +69 -0
  10. package/src/cli/doc/index.ts +9 -0
  11. package/src/cli/doc/post.ts +155 -0
  12. package/src/cli/doc/primitives.ts +25 -0
  13. package/src/cli/doc/render.ts +18 -0
  14. package/src/cli/doc/table.ts +114 -0
  15. package/src/cli/doc/thread.ts +46 -0
  16. package/src/cli/doc/tree.ts +126 -0
  17. package/src/cli/errors.ts +59 -0
  18. package/src/cli/exit-codes.ts +52 -0
  19. package/src/cli/feed.ts +177 -0
  20. package/src/cli/filter-dsl.ts +1411 -0
  21. package/src/cli/filter-errors.ts +208 -0
  22. package/src/cli/filter-help.ts +70 -0
  23. package/src/cli/filter-input.ts +54 -0
  24. package/src/cli/filter.ts +435 -0
  25. package/src/cli/graph.ts +472 -0
  26. package/src/cli/help.ts +14 -0
  27. package/src/cli/interval.ts +35 -0
  28. package/src/cli/jetstream.ts +173 -0
  29. package/src/cli/layers.ts +180 -0
  30. package/src/cli/logging.ts +136 -0
  31. package/src/cli/output-format.ts +26 -0
  32. package/src/cli/output.ts +82 -0
  33. package/src/cli/parse.ts +80 -0
  34. package/src/cli/post.ts +193 -0
  35. package/src/cli/preferences.ts +11 -0
  36. package/src/cli/query-fields.ts +247 -0
  37. package/src/cli/query.ts +415 -0
  38. package/src/cli/range.ts +44 -0
  39. package/src/cli/search.ts +465 -0
  40. package/src/cli/shared-options.ts +169 -0
  41. package/src/cli/shared.ts +20 -0
  42. package/src/cli/store-errors.ts +80 -0
  43. package/src/cli/store-tree.ts +392 -0
  44. package/src/cli/store.ts +395 -0
  45. package/src/cli/sync-factory.ts +107 -0
  46. package/src/cli/sync.ts +366 -0
  47. package/src/cli/view-thread.ts +196 -0
  48. package/src/cli/view.ts +47 -0
  49. package/src/cli/watch.ts +344 -0
  50. package/src/db/migrations/store-catalog/001_init.ts +14 -0
  51. package/src/db/migrations/store-index/001_init.ts +34 -0
  52. package/src/db/migrations/store-index/002_event_log.ts +24 -0
  53. package/src/db/migrations/store-index/003_fts_and_derived.ts +52 -0
  54. package/src/db/migrations/store-index/004_query_indexes.ts +9 -0
  55. package/src/db/migrations/store-index/005_post_lang.ts +15 -0
  56. package/src/db/migrations/store-index/006_has_embed.ts +10 -0
  57. package/src/db/migrations/store-index/007_event_seq_and_checkpoints.ts +68 -0
  58. package/src/domain/bsky.ts +467 -0
  59. package/src/domain/config.ts +11 -0
  60. package/src/domain/credentials.ts +6 -0
  61. package/src/domain/defaults.ts +8 -0
  62. package/src/domain/derivation.ts +55 -0
  63. package/src/domain/errors.ts +71 -0
  64. package/src/domain/events.ts +55 -0
  65. package/src/domain/extract.ts +64 -0
  66. package/src/domain/filter-describe.ts +551 -0
  67. package/src/domain/filter-explain.ts +9 -0
  68. package/src/domain/filter.ts +797 -0
  69. package/src/domain/format.ts +91 -0
  70. package/src/domain/index.ts +13 -0
  71. package/src/domain/indexes.ts +17 -0
  72. package/src/domain/policies.ts +16 -0
  73. package/src/domain/post.ts +88 -0
  74. package/src/domain/primitives.ts +50 -0
  75. package/src/domain/raw.ts +140 -0
  76. package/src/domain/store.ts +103 -0
  77. package/src/domain/sync.ts +211 -0
  78. package/src/domain/text-width.ts +56 -0
  79. package/src/services/app-config.ts +278 -0
  80. package/src/services/bsky-client.ts +2113 -0
  81. package/src/services/credential-store.ts +408 -0
  82. package/src/services/derivation-engine.ts +502 -0
  83. package/src/services/derivation-settings.ts +61 -0
  84. package/src/services/derivation-validator.ts +68 -0
  85. package/src/services/filter-compiler.ts +269 -0
  86. package/src/services/filter-library.ts +371 -0
  87. package/src/services/filter-runtime.ts +821 -0
  88. package/src/services/filter-settings.ts +30 -0
  89. package/src/services/identity-resolver.ts +563 -0
  90. package/src/services/jetstream-sync.ts +636 -0
  91. package/src/services/lineage-store.ts +89 -0
  92. package/src/services/link-validator.ts +244 -0
  93. package/src/services/output-manager.ts +274 -0
  94. package/src/services/post-parser.ts +62 -0
  95. package/src/services/profile-resolver.ts +223 -0
  96. package/src/services/resource-monitor.ts +106 -0
  97. package/src/services/shared.ts +69 -0
  98. package/src/services/store-cleaner.ts +43 -0
  99. package/src/services/store-commit.ts +168 -0
  100. package/src/services/store-db.ts +248 -0
  101. package/src/services/store-event-log.ts +285 -0
  102. package/src/services/store-index-sql.ts +289 -0
  103. package/src/services/store-index.ts +1152 -0
  104. package/src/services/store-keys.ts +4 -0
  105. package/src/services/store-manager.ts +358 -0
  106. package/src/services/store-stats.ts +522 -0
  107. package/src/services/store-writer.ts +200 -0
  108. package/src/services/sync-checkpoint-store.ts +169 -0
  109. package/src/services/sync-engine.ts +547 -0
  110. package/src/services/sync-reporter.ts +16 -0
  111. package/src/services/sync-settings.ts +72 -0
  112. package/src/services/trending-topics.ts +226 -0
  113. package/src/services/view-checkpoint-store.ts +238 -0
  114. package/src/typeclass/chunk.ts +84 -0
@@ -0,0 +1,2113 @@
1
+ /**
2
+ * Bluesky API client service providing authenticated access to AT Protocol APIs.
3
+ *
4
+ * This service wraps the @atproto/api client with Effect-based error handling,
5
+ * automatic retry logic, and rate limiting. It provides a streaming interface
6
+ * for paginated endpoints and supports multiple authentication sources.
7
+ *
8
+ * ## Authentication
9
+ *
10
+ * Credentials are resolved from (in order of priority):
11
+ * 1. Environment variables (BSKY_HANDLE, BSKY_PASSWORD)
12
+ * 2. Credential store (managed via `skygent credentials` commands)
13
+ * 3. Interactive prompts (if TTY available)
14
+ *
15
+ * ## Features
16
+ *
17
+ * - Automatic session refresh and retry on rate limits
18
+ * - Configurable retry policies via AppConfig
19
+ * - Streaming interface for paginated feeds
20
+ * - Type-safe API wrappers
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * import { Effect } from "effect";
25
+ * import { BskyClient } from "./services/bsky-client.js";
26
+ *
27
+ * const program = Effect.gen(function* () {
28
+ * const client = yield* BskyClient;
29
+ *
30
+ * // Stream posts from the authenticated user's timeline
31
+ * const posts = yield* client.getTimeline({ limit: 100 }).pipe(
32
+ * Effect.runCollect
33
+ * );
34
+ *
35
+ * // Get a specific post
36
+ * const post = yield* client.getPost("at://did:plc:abc/app.bsky.feed.post/123");
37
+ *
38
+ * // Search for posts
39
+ * const searchResults = yield* client.searchPosts("skygent").pipe(
40
+ * Effect.runCollect
41
+ * );
42
+ * });
43
+ * ```
44
+ *
45
+ * @module services/bsky-client
46
+ */
47
+
48
+ import { AtpAgent, AppBskyFeedDefs } from "@atproto/api";
49
+ import type {
50
+ AppBskyActorSearchActors,
51
+ AppBskyActorSearchActorsTypeahead,
52
+ AppBskyActorGetProfiles,
53
+ AppBskyFeedGetAuthorFeed,
54
+ AppBskyFeedGetFeed,
55
+ AppBskyFeedGetListFeed,
56
+ AppBskyFeedGetPostThread,
57
+ AppBskyFeedGetPosts,
58
+ AppBskyFeedGetFeedGenerator,
59
+ AppBskyFeedGetFeedGenerators,
60
+ AppBskyFeedGetActorFeeds,
61
+ AppBskyFeedGetLikes,
62
+ AppBskyFeedGetQuotes,
63
+ AppBskyFeedGetRepostedBy,
64
+ AppBskyFeedSearchPosts,
65
+ AppBskyFeedGetTimeline,
66
+ AppBskyFeedPost,
67
+ AppBskyGraphGetBlocks,
68
+ AppBskyGraphGetFollowers,
69
+ AppBskyGraphGetFollows,
70
+ AppBskyGraphGetKnownFollowers,
71
+ AppBskyGraphGetList,
72
+ AppBskyGraphGetLists,
73
+ AppBskyGraphGetMutes,
74
+ AppBskyGraphGetRelationships,
75
+ AppBskyNotificationListNotifications,
76
+ AppBskyUnspeccedGetPopularFeedGenerators,
77
+ AppBskyUnspeccedGetTrendingTopics,
78
+ ComAtprotoIdentityResolveIdentity
79
+ } from "@atproto/api";
80
+ import {
81
+ Chunk,
82
+ Clock,
83
+ Config,
84
+ Context,
85
+ Duration,
86
+ Effect,
87
+ Layer,
88
+ Option,
89
+ Redacted,
90
+ Ref,
91
+ Schedule,
92
+ Schema,
93
+ Stream
94
+ } from "effect";
95
+ import { AppConfigService } from "./app-config.js";
96
+ import { CredentialStore } from "./credential-store.js";
97
+ import { BskyError } from "../domain/errors.js";
98
+ import { RawPost } from "../domain/raw.js";
99
+ import {
100
+ BlockedAuthor,
101
+ EmbedAspectRatio,
102
+ EmbedExternal,
103
+ EmbedImage,
104
+ EmbedImages,
105
+ EmbedRecordBlocked,
106
+ EmbedRecordDetached,
107
+ EmbedRecordNotFound,
108
+ EmbedRecordTarget,
109
+ EmbedRecordUnknown,
110
+ EmbedRecordView,
111
+ EmbedRecord,
112
+ EmbedRecordWithMedia,
113
+ EmbedUnknown,
114
+ EmbedVideo,
115
+ FeedContext,
116
+ FeedGeneratorView,
117
+ FeedPostBlocked,
118
+ FeedPostNotFound,
119
+ FeedPostUnknown,
120
+ FeedPostViewRef,
121
+ FeedReasonPin,
122
+ FeedReasonRepost,
123
+ FeedReasonUnknown,
124
+ FeedReplyRef,
125
+ IdentityInfo,
126
+ Label,
127
+ ListItemView,
128
+ ListView,
129
+ PostLike,
130
+ AuthorFeedFilter,
131
+ PostEmbed,
132
+ PostMetrics,
133
+ PostViewerState,
134
+ ProfileBasic,
135
+ ProfileView,
136
+ RelationshipView
137
+ } from "../domain/bsky.js";
138
+ import { Did, PostCid, PostUri, Timestamp } from "../domain/primitives.js";
139
+
140
+ /**
141
+ * Options for retrieving the user's timeline.
142
+ */
143
+ export interface TimelineOptions {
144
+ /** Maximum number of posts to retrieve per page (default: 100) */
145
+ readonly limit?: number;
146
+ /** Pagination cursor for fetching the next page */
147
+ readonly cursor?: string;
148
+ }
149
+
150
+ /**
151
+ * Options for retrieving feed or list posts.
152
+ */
153
+ export interface FeedOptions {
154
+ /** Maximum number of posts to retrieve per page (default: 100) */
155
+ readonly limit?: number;
156
+ /** Pagination cursor for fetching the next page */
157
+ readonly cursor?: string;
158
+ }
159
+
160
+ /**
161
+ * Options for graph-related queries (followers, follows, etc.).
162
+ */
163
+ export interface GraphOptions {
164
+ /** Maximum number of results per page */
165
+ readonly limit?: number;
166
+ /** Pagination cursor for fetching the next page */
167
+ readonly cursor?: string;
168
+ }
169
+
170
+ /**
171
+ * Options for retrieving Bluesky lists.
172
+ */
173
+ export interface GraphListsOptions {
174
+ /** Maximum number of lists per page */
175
+ readonly limit?: number;
176
+ /** Pagination cursor for fetching the next page */
177
+ readonly cursor?: string;
178
+ /** Filter by list purpose ("modlist" or "curatelist") */
179
+ readonly purposes?: ReadonlyArray<"modlist" | "curatelist">;
180
+ }
181
+
182
+ /**
183
+ * Options for retrieving an author's feed.
184
+ */
185
+ export interface AuthorFeedOptions {
186
+ /** Maximum number of posts per page */
187
+ readonly limit?: number;
188
+ /** Pagination cursor for fetching the next page */
189
+ readonly cursor?: string;
190
+ /** Filter posts by type (posts, posts_and_author_threads, posts_no_replies, posts_with_media, posts_with_video) */
191
+ readonly filter?: AuthorFeedFilter;
192
+ /** Whether to include pinned posts */
193
+ readonly includePins?: boolean;
194
+ }
195
+
196
+ /**
197
+ * Options for retrieving a post thread.
198
+ */
199
+ export interface ThreadOptions {
200
+ /** How many levels of replies to fetch (default: 6) */
201
+ readonly depth?: number;
202
+ /** How many levels of parent posts to fetch (default: 6) */
203
+ readonly parentHeight?: number;
204
+ }
205
+
206
+ /**
207
+ * Options for retrieving notifications.
208
+ */
209
+ export interface NotificationsOptions {
210
+ /** Maximum number of notifications per page */
211
+ readonly limit?: number;
212
+ /** Pagination cursor for fetching the next page */
213
+ readonly cursor?: string;
214
+ }
215
+
216
+ /**
217
+ * Options for searching actors (users).
218
+ */
219
+ export interface ActorSearchOptions {
220
+ /** Maximum number of results per page */
221
+ readonly limit?: number;
222
+ /** Pagination cursor for fetching the next page */
223
+ readonly cursor?: string;
224
+ /** Use typeahead search for faster results */
225
+ readonly typeahead?: boolean;
226
+ }
227
+
228
+ /**
229
+ * Options for searching feeds.
230
+ */
231
+ export interface FeedSearchOptions {
232
+ /** Maximum number of results per page */
233
+ readonly limit?: number;
234
+ /** Pagination cursor for fetching the next page */
235
+ readonly cursor?: string;
236
+ }
237
+
238
+ /**
239
+ * Options for retrieving an actor's feeds.
240
+ */
241
+ export interface ActorFeedsOptions {
242
+ /** Maximum number of feeds per page */
243
+ readonly limit?: number;
244
+ /** Pagination cursor for fetching the next page */
245
+ readonly cursor?: string;
246
+ }
247
+
248
+ /**
249
+ * Options for retrieving engagement data (likes, reposts, quotes).
250
+ */
251
+ export interface EngagementOptions {
252
+ /** The post CID to query engagement for */
253
+ readonly cid?: string;
254
+ /** Maximum number of results per page */
255
+ readonly limit?: number;
256
+ /** Pagination cursor for fetching the next page */
257
+ readonly cursor?: string;
258
+ }
259
+
260
+ /**
261
+ * Options for searching posts across the Bluesky network.
262
+ */
263
+ export interface NetworkSearchOptions {
264
+ /** Maximum number of results per page */
265
+ readonly limit?: number;
266
+ /** Pagination cursor for fetching the next page */
267
+ readonly cursor?: string;
268
+ /** Sort order: "top" for relevance, "latest" for recency */
269
+ readonly sort?: "top" | "latest";
270
+ /** Filter posts after this timestamp (ISO 8601) */
271
+ readonly since?: string;
272
+ /** Filter posts before this timestamp (ISO 8601) */
273
+ readonly until?: string;
274
+ /** Filter posts mentioning this handle */
275
+ readonly mentions?: string;
276
+ /** Filter posts by this author handle */
277
+ readonly author?: string;
278
+ /** Filter posts by language code */
279
+ readonly lang?: string;
280
+ /** Filter posts containing links to this domain */
281
+ readonly domain?: string;
282
+ /** Filter posts containing this URL */
283
+ readonly url?: string;
284
+ /** Filter posts containing all of these hashtags */
285
+ readonly tags?: ReadonlyArray<string>;
286
+ }
287
+
288
+ type FeedViewPost = AppBskyFeedDefs.FeedViewPost;
289
+ type PostView = AppBskyFeedDefs.PostView;
290
+ type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost;
291
+
292
+
293
+ const extractBskyErrorDetails = (cause: unknown) => {
294
+ if (!cause || typeof cause !== "object") {
295
+ return {} as const;
296
+ }
297
+ const record = cause as {
298
+ status?: unknown;
299
+ statusCode?: unknown;
300
+ error?: unknown;
301
+ message?: unknown;
302
+ };
303
+ const status =
304
+ typeof record.status === "number"
305
+ ? record.status
306
+ : typeof record.statusCode === "number"
307
+ ? record.statusCode
308
+ : typeof (record.error as { status?: unknown })?.status === "number"
309
+ ? (record.error as { status: number }).status
310
+ : undefined;
311
+ const error =
312
+ typeof record.error === "string"
313
+ ? record.error
314
+ : typeof (record.error as { message?: unknown })?.message === "string"
315
+ ? (record.error as { message: string }).message
316
+ : undefined;
317
+ const detail =
318
+ typeof record.message === "string"
319
+ ? record.message
320
+ : typeof (record.error as { message?: unknown })?.message === "string"
321
+ ? (record.error as { message: string }).message
322
+ : undefined;
323
+ return {
324
+ ...(status !== undefined ? { status } : {}),
325
+ ...(error !== undefined ? { error } : {}),
326
+ ...(detail !== undefined ? { detail } : {})
327
+ } as const;
328
+ };
329
+
330
+ const formatBskyErrorMessage = (
331
+ fallback: string,
332
+ _cause: unknown,
333
+ details: ReturnType<typeof extractBskyErrorDetails>
334
+ ) => {
335
+ const status = details.status;
336
+ const summary = typeof status === "number" ? statusSummary(status) : undefined;
337
+ const detail = normalizeDetail(details.detail ?? details.error);
338
+
339
+ if (typeof status === "number") {
340
+ const summaryPart = summary ? `: ${summary}` : "";
341
+ const detailPart = detail && detail !== summary ? ` - ${detail}` : "";
342
+ return `${fallback} (HTTP ${status}${summaryPart}${detailPart})`;
343
+ }
344
+
345
+ return detail ? `${fallback} (${detail})` : fallback;
346
+ };
347
+
348
+ const statusSummary = (status: number): string | undefined => {
349
+ switch (status) {
350
+ case 400:
351
+ return "Bad request";
352
+ case 401:
353
+ return "Unauthorized";
354
+ case 403:
355
+ return "Forbidden";
356
+ case 404:
357
+ return "Not found";
358
+ case 409:
359
+ return "Conflict";
360
+ case 413:
361
+ return "Payload too large";
362
+ case 429:
363
+ return "Rate limited";
364
+ case 500:
365
+ return "Server error";
366
+ case 502:
367
+ return "Bad gateway";
368
+ case 503:
369
+ return "Service unavailable";
370
+ case 504:
371
+ return "Gateway timeout";
372
+ default:
373
+ return status >= 500 ? "Server error" : undefined;
374
+ }
375
+ };
376
+
377
+ const normalizeDetail = (detail?: string) => {
378
+ if (!detail) return undefined;
379
+ const trimmed = detail.trim();
380
+ if (trimmed.length === 0) return undefined;
381
+ return trimmed.length > 160 ? `${trimmed.slice(0, 157)}...` : trimmed;
382
+ };
383
+
384
+ const toBskyError = (message: string, operation?: string) => (cause: unknown) => {
385
+ const details = extractBskyErrorDetails(cause);
386
+ return BskyError.make({
387
+ message: formatBskyErrorMessage(message, cause, details),
388
+ cause,
389
+ operation,
390
+ ...details
391
+ });
392
+ };
393
+
394
+ const isRetryableCause = (cause: unknown) => {
395
+ if (!cause || typeof cause !== "object") return false;
396
+ const source =
397
+ "cause" in cause && typeof (cause as { cause?: unknown }).cause !== "undefined"
398
+ ? (cause as { cause?: unknown }).cause
399
+ : cause;
400
+ if (!source || typeof source !== "object") return false;
401
+ const record = source as { status?: unknown; statusCode?: unknown; error?: unknown };
402
+ const status =
403
+ typeof record.status === "number"
404
+ ? record.status
405
+ : typeof record.statusCode === "number"
406
+ ? record.statusCode
407
+ : typeof (record.error as { status?: unknown })?.status === "number"
408
+ ? (record.error as { status: number }).status
409
+ : undefined;
410
+ if (typeof status === "number") {
411
+ return status === 429 || (status >= 500 && status < 600);
412
+ }
413
+ const code = (record as { code?: unknown }).code;
414
+ return typeof code === "string" && ["ECONNRESET", "ETIMEDOUT", "EAI_AGAIN"].includes(code);
415
+ };
416
+
417
+ const mapAspectRatio = (input: unknown) => {
418
+ if (!input || typeof input !== "object") return undefined;
419
+ const ratio = input as { width?: unknown; height?: unknown };
420
+ if (typeof ratio.width !== "number" || typeof ratio.height !== "number") {
421
+ return undefined;
422
+ }
423
+ return EmbedAspectRatio.make({ width: ratio.width, height: ratio.height });
424
+ };
425
+
426
+ const decodeTimestamp = (value: unknown, message: string) =>
427
+ Schema.decodeUnknown(Timestamp)(value).pipe(
428
+ Effect.mapError(toBskyError(message))
429
+ );
430
+
431
+ const decodeDid = (value: unknown, message: string) =>
432
+ Schema.decodeUnknown(Did)(value).pipe(
433
+ Effect.mapError(toBskyError(message))
434
+ );
435
+
436
+ const decodePostUri = (value: unknown, message: string) =>
437
+ Schema.decodeUnknown(PostUri)(value).pipe(
438
+ Effect.mapError(toBskyError(message))
439
+ );
440
+
441
+ const decodePostCid = (value: unknown, message: string) =>
442
+ Schema.decodeUnknown(PostCid)(value).pipe(
443
+ Effect.mapError(toBskyError(message))
444
+ );
445
+
446
+ const decodePostUriOptional = (value: unknown, message: string) =>
447
+ typeof value === "undefined"
448
+ ? Effect.void.pipe(Effect.as(undefined))
449
+ : decodePostUri(value, message);
450
+
451
+ const decodePostCidOptional = (value: unknown, message: string) =>
452
+ typeof value === "undefined"
453
+ ? Effect.void.pipe(Effect.as(undefined))
454
+ : decodePostCid(value, message);
455
+
456
+ const decodeLabels = (labels: unknown) =>
457
+ Schema.decodeUnknown(Schema.Array(Label))(labels).pipe(
458
+ Effect.mapError(toBskyError("Invalid moderation labels"))
459
+ );
460
+
461
+ const decodeProfileBasic = (input: unknown) =>
462
+ Effect.gen(function* () {
463
+ if (!input || typeof input !== "object") {
464
+ return yield* BskyError.make({ message: "Invalid author payload" });
465
+ }
466
+ const author = input as Record<string, unknown>;
467
+ return yield* Schema.decodeUnknown(ProfileBasic)({
468
+ did: author.did,
469
+ handle: author.handle,
470
+ displayName: author.displayName,
471
+ pronouns: author.pronouns,
472
+ avatar: author.avatar,
473
+ associated: author.associated,
474
+ viewer: author.viewer,
475
+ labels: author.labels,
476
+ createdAt: author.createdAt,
477
+ verification: author.verification,
478
+ status: author.status,
479
+ debug: author.debug
480
+ }).pipe(Effect.mapError(toBskyError("Invalid author payload")));
481
+ });
482
+
483
+ const decodeProfileView = (input: unknown) =>
484
+ Effect.gen(function* () {
485
+ if (!input || typeof input !== "object") {
486
+ return yield* BskyError.make({ message: "Invalid profile payload" });
487
+ }
488
+ const author = input as Record<string, unknown>;
489
+ return yield* Schema.decodeUnknown(ProfileView)({
490
+ did: author.did,
491
+ handle: author.handle,
492
+ displayName: author.displayName,
493
+ pronouns: author.pronouns,
494
+ description: author.description,
495
+ avatar: author.avatar,
496
+ associated: author.associated,
497
+ indexedAt: author.indexedAt,
498
+ createdAt: author.createdAt,
499
+ viewer: author.viewer,
500
+ labels: author.labels,
501
+ verification: author.verification,
502
+ status: author.status,
503
+ debug: author.debug
504
+ }).pipe(Effect.mapError(toBskyError("Invalid profile payload")));
505
+ });
506
+
507
+ const decodeIdentityInfo = (input: unknown) =>
508
+ Effect.gen(function* () {
509
+ if (!input || typeof input !== "object") {
510
+ return yield* BskyError.make({ message: "Invalid identity payload" });
511
+ }
512
+ const info = input as Record<string, unknown>;
513
+ return yield* Schema.decodeUnknown(IdentityInfo)({
514
+ did: info.did,
515
+ handle: info.handle,
516
+ didDoc: info.didDoc
517
+ }).pipe(Effect.mapError(toBskyError("Invalid identity payload")));
518
+ });
519
+
520
+ const decodeFeedGeneratorView = (input: unknown) =>
521
+ Effect.gen(function* () {
522
+ if (!input || typeof input !== "object") {
523
+ return yield* BskyError.make({ message: "Invalid feed generator payload" });
524
+ }
525
+ const feed = input as Record<string, unknown>;
526
+ const creator = yield* decodeProfileView(feed.creator);
527
+ return yield* Schema.decodeUnknown(FeedGeneratorView)({
528
+ uri: feed.uri,
529
+ cid: feed.cid,
530
+ did: feed.did,
531
+ creator,
532
+ displayName: feed.displayName,
533
+ description: feed.description,
534
+ descriptionFacets: feed.descriptionFacets,
535
+ avatar: feed.avatar,
536
+ likeCount: feed.likeCount,
537
+ acceptsInteractions: feed.acceptsInteractions,
538
+ labels: feed.labels,
539
+ viewer: feed.viewer,
540
+ contentMode: feed.contentMode,
541
+ indexedAt: feed.indexedAt
542
+ }).pipe(Effect.mapError(toBskyError("Invalid feed generator payload")));
543
+ });
544
+
545
+ const decodeListView = (input: unknown) =>
546
+ Effect.gen(function* () {
547
+ if (!input || typeof input !== "object") {
548
+ return yield* BskyError.make({ message: "Invalid list payload" });
549
+ }
550
+ const list = input as Record<string, unknown>;
551
+ const creator = yield* decodeProfileView(list.creator);
552
+ return yield* Schema.decodeUnknown(ListView)({
553
+ uri: list.uri,
554
+ cid: list.cid,
555
+ creator,
556
+ name: list.name,
557
+ purpose: list.purpose,
558
+ description: list.description,
559
+ descriptionFacets: list.descriptionFacets,
560
+ avatar: list.avatar,
561
+ listItemCount: list.listItemCount,
562
+ labels: list.labels,
563
+ viewer: list.viewer,
564
+ indexedAt: list.indexedAt
565
+ }).pipe(Effect.mapError(toBskyError("Invalid list payload")));
566
+ });
567
+
568
+ const decodeListItemView = (input: unknown) =>
569
+ Effect.gen(function* () {
570
+ if (!input || typeof input !== "object") {
571
+ return yield* BskyError.make({ message: "Invalid list item payload" });
572
+ }
573
+ const item = input as Record<string, unknown>;
574
+ const subject = yield* decodeProfileView(item.subject);
575
+ return yield* Schema.decodeUnknown(ListItemView)({
576
+ uri: item.uri,
577
+ subject
578
+ }).pipe(Effect.mapError(toBskyError("Invalid list item payload")));
579
+ });
580
+
581
+ const decodeRelationshipView = (input: unknown) =>
582
+ Schema.decodeUnknown(RelationshipView)(input).pipe(
583
+ Effect.mapError(toBskyError("Invalid relationship payload"))
584
+ );
585
+
586
+ const decodePostLike = (input: unknown) =>
587
+ Effect.gen(function* () {
588
+ if (!input || typeof input !== "object") {
589
+ return yield* BskyError.make({ message: "Invalid like payload" });
590
+ }
591
+ const like = input as Record<string, unknown>;
592
+ const actor = yield* decodeProfileView(like.actor);
593
+ const createdAt = yield* decodeTimestamp(like.createdAt, "Invalid like timestamp");
594
+ const indexedAt = yield* decodeTimestamp(like.indexedAt, "Invalid like timestamp");
595
+ return yield* Schema.decodeUnknown(PostLike)({
596
+ actor,
597
+ createdAt,
598
+ indexedAt
599
+ }).pipe(Effect.mapError(toBskyError("Invalid like payload")));
600
+ });
601
+
602
+ const decodeViewerState = (input: unknown) =>
603
+ Schema.decodeUnknown(PostViewerState)(input).pipe(
604
+ Effect.mapError(toBskyError("Invalid viewer state"))
605
+ );
606
+
607
+ const mapBlockedAuthor = (input: unknown) =>
608
+ Effect.gen(function* () {
609
+ if (!input || typeof input !== "object") {
610
+ return yield* BskyError.make({ message: "Invalid blocked author payload" });
611
+ }
612
+ const author = input as Record<string, unknown>;
613
+ return yield* Schema.decodeUnknown(BlockedAuthor)({
614
+ did: author.did,
615
+ viewer: author.viewer
616
+ }).pipe(Effect.mapError(toBskyError("Invalid blocked author payload")));
617
+ });
618
+
619
+ const mapEmbedRecordTarget = (
620
+ record: unknown
621
+ ): Effect.Effect<EmbedRecordTarget, BskyError> =>
622
+ Effect.gen(function* () {
623
+ if (!record || typeof record !== "object") {
624
+ return EmbedRecordUnknown.make({ rawType: "unknown", data: record });
625
+ }
626
+ const typed = record as { $type?: string };
627
+ const recordType = typed.$type;
628
+ switch (recordType) {
629
+ case "app.bsky.embed.record#viewRecord": {
630
+ const view = record as {
631
+ uri?: unknown;
632
+ cid?: unknown;
633
+ author?: unknown;
634
+ value?: unknown;
635
+ labels?: unknown;
636
+ replyCount?: unknown;
637
+ repostCount?: unknown;
638
+ likeCount?: unknown;
639
+ quoteCount?: unknown;
640
+ embeds?: unknown;
641
+ indexedAt?: unknown;
642
+ };
643
+ const author = yield* decodeProfileBasic(view.author);
644
+ const labels =
645
+ view.labels && Array.isArray(view.labels)
646
+ ? yield* decodeLabels(view.labels)
647
+ : undefined;
648
+ const metrics = (() => {
649
+ const data = {
650
+ replyCount: view.replyCount as number | undefined,
651
+ repostCount: view.repostCount as number | undefined,
652
+ likeCount: view.likeCount as number | undefined,
653
+ quoteCount: view.quoteCount as number | undefined
654
+ };
655
+ const hasAny = Object.values(data).some((value) => value !== undefined);
656
+ return hasAny ? PostMetrics.make(data) : undefined;
657
+ })();
658
+ const indexedAt = yield* decodeTimestamp(
659
+ view.indexedAt,
660
+ "Invalid record embed timestamp"
661
+ );
662
+ const embeds: Array<PostEmbed> | undefined =
663
+ Array.isArray(view.embeds)
664
+ ? yield* Effect.forEach(
665
+ view.embeds,
666
+ (entry) => mapEmbedView(entry),
667
+ { concurrency: "unbounded" }
668
+ ).pipe(
669
+ Effect.map((values) =>
670
+ values.filter((value): value is PostEmbed => value !== undefined)
671
+ )
672
+ )
673
+ : undefined;
674
+ return EmbedRecordView.make({
675
+ uri: yield* decodePostUri(
676
+ view.uri,
677
+ "Invalid record embed URI"
678
+ ),
679
+ cid: yield* decodePostCid(
680
+ view.cid,
681
+ "Invalid record embed CID"
682
+ ),
683
+ author,
684
+ value: view.value ?? record,
685
+ labels,
686
+ metrics,
687
+ embeds,
688
+ indexedAt
689
+ });
690
+ }
691
+ case "app.bsky.embed.record#viewNotFound":
692
+ return EmbedRecordNotFound.make({
693
+ uri: yield* decodePostUri(
694
+ (record as { uri?: unknown }).uri,
695
+ "Invalid record embed URI"
696
+ ),
697
+ notFound: true
698
+ });
699
+ case "app.bsky.embed.record#viewBlocked": {
700
+ const author = yield* mapBlockedAuthor(
701
+ (record as { author?: unknown }).author
702
+ );
703
+ return EmbedRecordBlocked.make({
704
+ uri: yield* decodePostUri(
705
+ (record as { uri?: unknown }).uri,
706
+ "Invalid record embed URI"
707
+ ),
708
+ blocked: true,
709
+ author
710
+ });
711
+ }
712
+ case "app.bsky.embed.record#viewDetached":
713
+ return EmbedRecordDetached.make({
714
+ uri: yield* decodePostUri(
715
+ (record as { uri?: unknown }).uri,
716
+ "Invalid record embed URI"
717
+ ),
718
+ detached: true
719
+ });
720
+ default:
721
+ return EmbedRecordUnknown.make({
722
+ rawType: typeof recordType === "string" ? recordType : "unknown",
723
+ data: record
724
+ });
725
+ }
726
+ });
727
+
728
+ const mapEmbedView = (
729
+ embed: unknown
730
+ ): Effect.Effect<PostEmbed | undefined, BskyError> =>
731
+ Effect.gen(function* () {
732
+ if (!embed || typeof embed !== "object") return undefined;
733
+ const typed = embed as { $type?: string };
734
+ switch (typed.$type) {
735
+ case "app.bsky.embed.images#view": {
736
+ const images = (embed as { images?: Array<any> }).images ?? [];
737
+ return EmbedImages.make({
738
+ images: images
739
+ .filter((image) => image && typeof image === "object")
740
+ .map((image) =>
741
+ EmbedImage.make({
742
+ thumb: String(image.thumb ?? ""),
743
+ fullsize: String(image.fullsize ?? ""),
744
+ alt: String(image.alt ?? ""),
745
+ aspectRatio: mapAspectRatio(image.aspectRatio)
746
+ })
747
+ )
748
+ });
749
+ }
750
+ case "app.bsky.embed.external#view": {
751
+ const external = (embed as { external?: any }).external ?? {};
752
+ return EmbedExternal.make({
753
+ uri: String(external.uri ?? ""),
754
+ title: String(external.title ?? ""),
755
+ description: String(external.description ?? ""),
756
+ thumb: external.thumb ? String(external.thumb) : undefined
757
+ });
758
+ }
759
+ case "app.bsky.embed.video#view": {
760
+ return EmbedVideo.make({
761
+ cid: String((embed as { cid?: unknown }).cid ?? ""),
762
+ playlist: String((embed as { playlist?: unknown }).playlist ?? ""),
763
+ thumbnail: (embed as { thumbnail?: unknown }).thumbnail
764
+ ? String((embed as { thumbnail?: unknown }).thumbnail)
765
+ : undefined,
766
+ alt: (embed as { alt?: unknown }).alt
767
+ ? String((embed as { alt?: unknown }).alt)
768
+ : undefined,
769
+ aspectRatio: mapAspectRatio((embed as { aspectRatio?: unknown }).aspectRatio)
770
+ });
771
+ }
772
+ case "app.bsky.embed.record#view": {
773
+ const record = (embed as { record?: unknown }).record;
774
+ const recordType = record && typeof record === "object" ? (record as { $type?: string }).$type : undefined;
775
+ const mapped = yield* mapEmbedRecordTarget(record ?? embed);
776
+ return EmbedRecord.make({
777
+ recordType,
778
+ record: mapped
779
+ });
780
+ }
781
+ case "app.bsky.embed.recordWithMedia#view": {
782
+ const record = (embed as { record?: unknown }).record;
783
+ const recordType = record && typeof record === "object" ? (record as { $type?: string }).$type : undefined;
784
+ const mediaCandidate: PostEmbed | undefined = yield* mapEmbedView(
785
+ (embed as { media?: unknown }).media
786
+ );
787
+ const media: PostEmbed | unknown =
788
+ mediaCandidate &&
789
+ (mediaCandidate._tag === "Images" ||
790
+ mediaCandidate._tag === "External" ||
791
+ mediaCandidate._tag === "Video")
792
+ ? mediaCandidate
793
+ : (embed as { media?: unknown }).media;
794
+ const mapped: EmbedRecordTarget = yield* mapEmbedRecordTarget(record ?? embed);
795
+ return EmbedRecordWithMedia.make({
796
+ recordType,
797
+ record: mapped,
798
+ media
799
+ });
800
+ }
801
+ default:
802
+ if (typed.$type) {
803
+ return EmbedUnknown.make({ rawType: typed.$type, data: embed });
804
+ }
805
+ return undefined;
806
+ }
807
+ });
808
+
809
+ const metricsFromPostView = (post: PostView) => {
810
+ const data = {
811
+ replyCount: post.replyCount,
812
+ repostCount: post.repostCount,
813
+ likeCount: post.likeCount,
814
+ quoteCount: post.quoteCount,
815
+ bookmarkCount: post.bookmarkCount
816
+ };
817
+ const hasAny = Object.values(data).some((value) => value !== undefined);
818
+ return hasAny ? PostMetrics.make(data) : undefined;
819
+ };
820
+
821
+ const mapFeedPostReference = (input: unknown) =>
822
+ Effect.gen(function* () {
823
+ if (!input || typeof input !== "object") {
824
+ return FeedPostUnknown.make({ rawType: "unknown", data: input });
825
+ }
826
+ const candidate = input as {
827
+ $type?: unknown;
828
+ uri?: unknown;
829
+ cid?: unknown;
830
+ author?: unknown;
831
+ notFound?: unknown;
832
+ blocked?: unknown;
833
+ };
834
+ const type = typeof candidate.$type === "string" ? candidate.$type : undefined;
835
+ if (
836
+ type === "app.bsky.feed.defs#postView" ||
837
+ (candidate.uri && candidate.cid && candidate.author)
838
+ ) {
839
+ const post = input as PostView;
840
+ const author = yield* decodeProfileBasic(post.author);
841
+ const labels =
842
+ post.labels && post.labels.length > 0
843
+ ? yield* decodeLabels(post.labels)
844
+ : undefined;
845
+ const viewer = post.viewer ? yield* decodeViewerState(post.viewer) : undefined;
846
+ const indexedAt = yield* decodeTimestamp(
847
+ post.indexedAt,
848
+ "Invalid feed post timestamp"
849
+ );
850
+ return FeedPostViewRef.make({
851
+ uri: yield* decodePostUri(
852
+ post.uri,
853
+ "Invalid feed post URI"
854
+ ),
855
+ cid: yield* decodePostCid(
856
+ post.cid,
857
+ "Invalid feed post CID"
858
+ ),
859
+ author,
860
+ indexedAt,
861
+ labels,
862
+ viewer
863
+ });
864
+ }
865
+ if (type === "app.bsky.feed.defs#notFoundPost" || candidate.notFound === true) {
866
+ return FeedPostNotFound.make({
867
+ uri: yield* decodePostUri(
868
+ candidate.uri,
869
+ "Invalid feed post URI"
870
+ ),
871
+ notFound: true
872
+ });
873
+ }
874
+ if (type === "app.bsky.feed.defs#blockedPost" || candidate.blocked === true) {
875
+ const author = yield* mapBlockedAuthor(
876
+ (candidate as { author?: unknown }).author
877
+ );
878
+ return FeedPostBlocked.make({
879
+ uri: yield* decodePostUri(
880
+ candidate.uri,
881
+ "Invalid feed post URI"
882
+ ),
883
+ blocked: true,
884
+ author
885
+ });
886
+ }
887
+ return FeedPostUnknown.make({
888
+ rawType: type ?? "unknown",
889
+ data: input
890
+ });
891
+ });
892
+
893
+ const mapFeedReplyRef = (input: unknown) =>
894
+ Effect.gen(function* () {
895
+ if (!input || typeof input !== "object") {
896
+ return yield* BskyError.make({ message: "Invalid feed reply payload" });
897
+ }
898
+ const reply = input as {
899
+ root?: unknown;
900
+ parent?: unknown;
901
+ grandparentAuthor?: unknown;
902
+ };
903
+ const root = yield* mapFeedPostReference(reply.root);
904
+ const parent = yield* mapFeedPostReference(reply.parent);
905
+ const grandparentAuthor = reply.grandparentAuthor
906
+ ? yield* decodeProfileBasic(reply.grandparentAuthor)
907
+ : undefined;
908
+ return FeedReplyRef.make({
909
+ root,
910
+ parent,
911
+ grandparentAuthor
912
+ });
913
+ });
914
+
915
+ const mapFeedReason = (input: unknown) =>
916
+ Effect.gen(function* () {
917
+ if (!input || typeof input !== "object") {
918
+ return FeedReasonUnknown.make({ rawType: "unknown", data: input });
919
+ }
920
+ const reason = input as { $type?: unknown };
921
+ const type = typeof reason.$type === "string" ? reason.$type : undefined;
922
+ switch (type) {
923
+ case "app.bsky.feed.defs#reasonRepost": {
924
+ const raw = reason as {
925
+ by?: unknown;
926
+ uri?: unknown;
927
+ cid?: unknown;
928
+ indexedAt?: unknown;
929
+ };
930
+ const by = yield* decodeProfileBasic(raw.by);
931
+ const indexedAt = yield* decodeTimestamp(
932
+ raw.indexedAt,
933
+ "Invalid reason timestamp"
934
+ );
935
+ return FeedReasonRepost.make({
936
+ by,
937
+ uri: yield* decodePostUriOptional(
938
+ raw.uri,
939
+ "Invalid repost URI"
940
+ ),
941
+ cid: yield* decodePostCidOptional(
942
+ raw.cid,
943
+ "Invalid repost CID"
944
+ ),
945
+ indexedAt
946
+ });
947
+ }
948
+ case "app.bsky.feed.defs#reasonPin":
949
+ return FeedReasonPin.make({});
950
+ default:
951
+ return FeedReasonUnknown.make({
952
+ rawType: type ?? "unknown",
953
+ data: input
954
+ });
955
+ }
956
+ });
957
+
958
+ const mapFeedContext = (item: FeedViewPost) =>
959
+ Effect.gen(function* () {
960
+ const reply = item.reply ? yield* mapFeedReplyRef(item.reply) : undefined;
961
+ const reason = item.reason ? yield* mapFeedReason(item.reason) : undefined;
962
+ return yield* Schema.decodeUnknown(FeedContext)({
963
+ reply,
964
+ reason,
965
+ feedContext: item.feedContext,
966
+ reqId: item.reqId
967
+ }).pipe(Effect.mapError(toBskyError("Invalid feed context payload")));
968
+ });
969
+
970
+ const withCursor = <T extends Record<string, unknown>>(
971
+ params: T,
972
+ cursor: string | undefined
973
+ ): T & { cursor?: string } =>
974
+ typeof cursor === "string" ? { ...params, cursor } : params;
975
+
976
+ const toRawPost = (post: PostView, feed?: FeedContext) =>
977
+ Effect.gen(function* () {
978
+ const embed = yield* mapEmbedView(post.embed);
979
+ const authorProfile = yield* decodeProfileBasic(post.author);
980
+ const raw = {
981
+ uri: post.uri,
982
+ cid: post.cid,
983
+ author: post.author.handle,
984
+ authorDid: post.author.did,
985
+ authorProfile,
986
+ record: post.record,
987
+ indexedAt: post.indexedAt,
988
+ labels: post.labels,
989
+ metrics: metricsFromPostView(post),
990
+ embed,
991
+ viewer: post.viewer,
992
+ threadgate: post.threadgate,
993
+ debug: post.debug,
994
+ feed
995
+ };
996
+
997
+ return yield* Schema.decodeUnknown(RawPost)(raw).pipe(
998
+ Effect.mapError(toBskyError("Invalid post payload"))
999
+ );
1000
+ });
1001
+
1002
+ const toRawPostsFromFeed = (feed: ReadonlyArray<FeedViewPost>) =>
1003
+ Effect.partition(feed, (item, index) =>
1004
+ Effect.gen(function* () {
1005
+ const context = yield* mapFeedContext(item);
1006
+ return yield* toRawPost(item.post, context);
1007
+ }).pipe(
1008
+ Effect.tapError((error) =>
1009
+ Effect.logWarning(`Skipping malformed feed item at index ${index}`, {
1010
+ uri: item.post?.uri,
1011
+ error: error.message
1012
+ })
1013
+ )
1014
+ )
1015
+ ).pipe(
1016
+ Effect.tap(([errors, successes]) => {
1017
+ if (errors.length > 0) {
1018
+ return Effect.log(
1019
+ `Feed sync: processed ${successes.length} posts, skipped ${errors.length} malformed items`
1020
+ );
1021
+ }
1022
+ return Effect.void;
1023
+ }),
1024
+ Effect.map(([_, successes]) => successes)
1025
+ );
1026
+
1027
+ const chunkArray = <A>(
1028
+ items: ReadonlyArray<A>,
1029
+ size: number
1030
+ ): Array<Array<A>> => {
1031
+ if (items.length === 0) return [];
1032
+ const chunkSize = Math.max(1, Math.trunc(size));
1033
+ const chunks: Array<Array<A>> = [];
1034
+ for (let index = 0; index < items.length; index += chunkSize) {
1035
+ chunks.push(items.slice(index, index + chunkSize));
1036
+ }
1037
+ return chunks;
1038
+ };
1039
+
1040
+ const isPostRecord = (record: unknown): record is AppBskyFeedPost.Record =>
1041
+ typeof record === "object" &&
1042
+ record !== null &&
1043
+ (record as { $type?: unknown }).$type === "app.bsky.feed.post";
1044
+
1045
+ const collectThreadChildren = (node: ThreadViewPost): ReadonlyArray<ThreadViewPost> => {
1046
+ const items: Array<ThreadViewPost> = [];
1047
+ const parent = node.parent;
1048
+ if (parent && AppBskyFeedDefs.isThreadViewPost(parent)) {
1049
+ items.push(parent);
1050
+ }
1051
+ if (Array.isArray(node.replies)) {
1052
+ for (const reply of node.replies) {
1053
+ if (AppBskyFeedDefs.isThreadViewPost(reply)) {
1054
+ items.push(reply);
1055
+ }
1056
+ }
1057
+ }
1058
+ return items;
1059
+ };
1060
+
1061
+ const unfoldThread = (root: ThreadViewPost) =>
1062
+ Stream.unfoldEffect(
1063
+ { queue: [root] as ReadonlyArray<ThreadViewPost>, seen: new Set<string>() },
1064
+ (state) =>
1065
+ Effect.sync(() => {
1066
+ const queue = state.queue.slice();
1067
+ while (queue.length > 0) {
1068
+ const next = queue.shift()!;
1069
+ const nextUri = next.post?.uri;
1070
+ const children = collectThreadChildren(next);
1071
+ queue.push(...children);
1072
+ if (typeof nextUri === "string") {
1073
+ if (state.seen.has(nextUri)) {
1074
+ continue;
1075
+ }
1076
+ state.seen.add(nextUri);
1077
+ }
1078
+ return Option.some([next, { queue, seen: state.seen }] as const);
1079
+ }
1080
+ return Option.none();
1081
+ })
1082
+ );
1083
+
1084
+ const toRawPostsFromThread = (root: ThreadViewPost) =>
1085
+ unfoldThread(root).pipe(
1086
+ Stream.mapEffect((node) => toRawPost(node.post)),
1087
+ Stream.runCollect,
1088
+ Effect.map(Chunk.toReadonlyArray)
1089
+ );
1090
+
1091
+ /**
1092
+ * Service for interacting with the Bluesky (AT Protocol) API.
1093
+ *
1094
+ * Provides authenticated access to all major Bluesky API endpoints including:
1095
+ * - Timeline and feed retrieval
1096
+ * - Post search and discovery
1097
+ * - Social graph operations (followers, follows, lists)
1098
+ * - Engagement data (likes, reposts, quotes)
1099
+ * - Notifications
1100
+ * - Thread viewing
1101
+ *
1102
+ * ## Authentication
1103
+ *
1104
+ * The client automatically handles authentication using credentials resolved
1105
+ * from environment variables, credential store, or interactive prompts.
1106
+ *
1107
+ * ## Error Handling
1108
+ *
1109
+ * All methods return Effect values that can fail with `BskyError`. Common
1110
+ * error scenarios include:
1111
+ * - Network failures (automatically retried)
1112
+ * - Rate limiting (automatically retried with backoff)
1113
+ * - Authentication errors
1114
+ * - Invalid post/feed URIs
1115
+ *
1116
+ * ## Streaming
1117
+ *
1118
+ * Paginated endpoints (timeline, feeds, search) return `Stream.Stream` values
1119
+ * that automatically handle pagination. Use `Effect.runCollect` to gather
1120
+ * all results or process them incrementally.
1121
+ *
1122
+ * @example
1123
+ * ```ts
1124
+ * import { Effect, Stream } from "effect";
1125
+ * import { BskyClient } from "./services/bsky-client.js";
1126
+ *
1127
+ * const program = Effect.gen(function* () {
1128
+ * const client = yield* BskyClient;
1129
+ *
1130
+ * // Get timeline as a stream
1131
+ * const timelineStream = client.getTimeline({ limit: 100 });
1132
+ *
1133
+ * // Collect all posts
1134
+ * const allPosts = yield* timelineStream.pipe(Stream.runCollect);
1135
+ *
1136
+ * // Get a specific post
1137
+ * const post = yield* client.getPost("at://did:plc:abc/app.bsky.feed.post/123");
1138
+ *
1139
+ * // Search for posts
1140
+ * const results = yield* client.searchPosts("typescript", {
1141
+ * sort: "latest",
1142
+ * limit: 50
1143
+ * }).pipe(Stream.runCollect);
1144
+ * });
1145
+ * ```
1146
+ */
1147
+ export class BskyClient extends Context.Tag("@skygent/BskyClient")<
1148
+ BskyClient,
1149
+ {
1150
+ /**
1151
+ * Get the authenticated user's home timeline.
1152
+ *
1153
+ * Returns a stream of posts from followed accounts.
1154
+ *
1155
+ * @param opts - Pagination options
1156
+ * @returns Stream of posts from the timeline
1157
+ */
1158
+ readonly getTimeline: (opts?: TimelineOptions) => Stream.Stream<RawPost, BskyError>;
1159
+ readonly getNotifications: (
1160
+ opts?: NotificationsOptions
1161
+ ) => Stream.Stream<RawPost, BskyError>;
1162
+ readonly getFeed: (
1163
+ uri: string,
1164
+ opts?: FeedOptions
1165
+ ) => Stream.Stream<RawPost, BskyError>;
1166
+ readonly getListFeed: (
1167
+ uri: string,
1168
+ opts?: FeedOptions
1169
+ ) => Stream.Stream<RawPost, BskyError>;
1170
+ readonly getAuthorFeed: (
1171
+ actor: string,
1172
+ opts?: AuthorFeedOptions
1173
+ ) => Stream.Stream<RawPost, BskyError>;
1174
+ readonly getPost: (uri: string) => Effect.Effect<RawPost, BskyError>;
1175
+ readonly getPostThread: (
1176
+ uri: string,
1177
+ opts?: ThreadOptions
1178
+ ) => Effect.Effect<ReadonlyArray<RawPost>, BskyError>;
1179
+ readonly getFollowers: (
1180
+ actor: string,
1181
+ opts?: GraphOptions
1182
+ ) => Effect.Effect<{ readonly subject: ProfileView; readonly followers: ReadonlyArray<ProfileView>; readonly cursor?: string }, BskyError>;
1183
+ readonly getFollows: (
1184
+ actor: string,
1185
+ opts?: GraphOptions
1186
+ ) => Effect.Effect<{ readonly subject: ProfileView; readonly follows: ReadonlyArray<ProfileView>; readonly cursor?: string }, BskyError>;
1187
+ readonly getKnownFollowers: (
1188
+ actor: string,
1189
+ opts?: GraphOptions
1190
+ ) => Effect.Effect<{ readonly subject: ProfileView; readonly followers: ReadonlyArray<ProfileView>; readonly cursor?: string }, BskyError>;
1191
+ readonly getRelationships: (
1192
+ actor: string,
1193
+ others: ReadonlyArray<string>
1194
+ ) => Effect.Effect<{ readonly actor: string; readonly relationships: ReadonlyArray<RelationshipView> }, BskyError>;
1195
+ readonly getList: (
1196
+ uri: string,
1197
+ opts?: GraphOptions
1198
+ ) => Effect.Effect<{ readonly list: ListView; readonly items: ReadonlyArray<ListItemView>; readonly cursor?: string }, BskyError>;
1199
+ readonly getLists: (
1200
+ actor: string,
1201
+ opts?: GraphListsOptions
1202
+ ) => Effect.Effect<{ readonly lists: ReadonlyArray<ListView>; readonly cursor?: string }, BskyError>;
1203
+ readonly getBlocks: (
1204
+ opts?: GraphOptions
1205
+ ) => Effect.Effect<{ readonly blocks: ReadonlyArray<ProfileView>; readonly cursor?: string }, BskyError>;
1206
+ readonly getMutes: (
1207
+ opts?: GraphOptions
1208
+ ) => Effect.Effect<{ readonly mutes: ReadonlyArray<ProfileView>; readonly cursor?: string }, BskyError>;
1209
+ readonly getFeedGenerator: (
1210
+ uri: string
1211
+ ) => Effect.Effect<{ readonly view: FeedGeneratorView; readonly isOnline: boolean; readonly isValid: boolean }, BskyError>;
1212
+ readonly getFeedGenerators: (
1213
+ uris: ReadonlyArray<string>
1214
+ ) => Effect.Effect<{ readonly feeds: ReadonlyArray<FeedGeneratorView> }, BskyError>;
1215
+ readonly getActorFeeds: (
1216
+ actor: string,
1217
+ opts?: ActorFeedsOptions
1218
+ ) => Effect.Effect<{ readonly feeds: ReadonlyArray<FeedGeneratorView>; readonly cursor?: string }, BskyError>;
1219
+ readonly getLikes: (
1220
+ uri: string,
1221
+ opts?: EngagementOptions
1222
+ ) => Effect.Effect<{ readonly uri: string; readonly cid?: string; readonly likes: ReadonlyArray<PostLike>; readonly cursor?: string }, BskyError>;
1223
+ readonly getRepostedBy: (
1224
+ uri: string,
1225
+ opts?: EngagementOptions
1226
+ ) => Effect.Effect<{ readonly uri: string; readonly cid?: string; readonly repostedBy: ReadonlyArray<ProfileView>; readonly cursor?: string }, BskyError>;
1227
+ readonly getQuotes: (
1228
+ uri: string,
1229
+ opts?: EngagementOptions
1230
+ ) => Effect.Effect<{ readonly uri: string; readonly cid?: string; readonly posts: ReadonlyArray<RawPost>; readonly cursor?: string }, BskyError>;
1231
+ readonly resolveHandle: (handle: string) => Effect.Effect<Did, BskyError>;
1232
+ readonly resolveIdentity: (
1233
+ identifier: string
1234
+ ) => Effect.Effect<IdentityInfo, BskyError>;
1235
+ readonly getProfiles: (
1236
+ actors: ReadonlyArray<string>
1237
+ ) => Effect.Effect<ReadonlyArray<ProfileBasic>, BskyError>;
1238
+ readonly searchActors: (
1239
+ query: string,
1240
+ opts?: ActorSearchOptions
1241
+ ) => Effect.Effect<{ readonly actors: ReadonlyArray<ProfileView>; readonly cursor?: string }, BskyError>;
1242
+ readonly searchFeedGenerators: (
1243
+ query: string,
1244
+ opts?: FeedSearchOptions
1245
+ ) => Effect.Effect<{ readonly feeds: ReadonlyArray<FeedGeneratorView>; readonly cursor?: string }, BskyError>;
1246
+ readonly searchPosts: (
1247
+ query: string,
1248
+ opts?: NetworkSearchOptions
1249
+ ) => Effect.Effect<{ readonly posts: ReadonlyArray<RawPost>; readonly cursor?: string; readonly hitsTotal?: number }, BskyError>;
1250
+ readonly getTrendingTopics: () => Effect.Effect<ReadonlyArray<string>, BskyError>;
1251
+ }
1252
+ >() {
1253
+ static readonly layer = Layer.effect(
1254
+ BskyClient,
1255
+ Effect.gen(function* () {
1256
+ const config = yield* AppConfigService;
1257
+ const credentials = yield* CredentialStore;
1258
+ const agent = new AtpAgent({ service: config.service });
1259
+
1260
+ const minInterval = yield* Config.duration("SKYGENT_BSKY_RATE_LIMIT").pipe(
1261
+ Config.withDefault(Duration.millis(250))
1262
+ );
1263
+ const retryBase = yield* Config.duration("SKYGENT_BSKY_RETRY_BASE").pipe(
1264
+ Config.withDefault(Duration.millis(250))
1265
+ );
1266
+ const retryMax = yield* Config.integer("SKYGENT_BSKY_RETRY_MAX").pipe(
1267
+ Config.withDefault(5)
1268
+ );
1269
+
1270
+ const limiter = yield* Effect.makeSemaphore(1);
1271
+ const lastCallRef = yield* Ref.make(0);
1272
+ const minIntervalMs = Duration.toMillis(minInterval);
1273
+
1274
+ const withRateLimit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
1275
+ limiter.withPermits(1)(
1276
+ Effect.gen(function* () {
1277
+ const now = yield* Clock.currentTimeMillis;
1278
+ const last = yield* Ref.get(lastCallRef);
1279
+ const waitMs = Math.max(0, minIntervalMs - (now - last));
1280
+ if (waitMs > 0) {
1281
+ yield* Effect.sleep(Duration.millis(waitMs));
1282
+ }
1283
+ return yield* effect;
1284
+ }).pipe(
1285
+ Effect.ensuring(
1286
+ Clock.currentTimeMillis.pipe(
1287
+ Effect.flatMap((now) => Ref.set(lastCallRef, now))
1288
+ )
1289
+ )
1290
+ )
1291
+ );
1292
+
1293
+ const retrySchedule = Schedule.exponential(retryBase).pipe(
1294
+ Schedule.jittered,
1295
+ Schedule.intersect(Schedule.recurWhile(isRetryableCause)),
1296
+ Schedule.intersect(Schedule.recurs(retryMax))
1297
+ );
1298
+
1299
+ const withRetry = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
1300
+ effect.pipe(Effect.retry(retrySchedule));
1301
+
1302
+ const ensureAuth = (required: boolean) =>
1303
+ Effect.gen(function* () {
1304
+ if (agent.hasSession) {
1305
+ return;
1306
+ }
1307
+ const creds = yield* credentials
1308
+ .get()
1309
+ .pipe(Effect.mapError(toBskyError("Failed to load credentials", "loadCredentials")));
1310
+ if (Option.isNone(creds)) {
1311
+ if (required) {
1312
+ return yield* BskyError.make({
1313
+ message:
1314
+ "Missing Bluesky credentials. Provide identifier and password."
1315
+ });
1316
+ }
1317
+ return;
1318
+ }
1319
+ const value = creds.value;
1320
+ yield* withRetry(
1321
+ withRateLimit(
1322
+ Effect.tryPromise(() =>
1323
+ agent.login({
1324
+ identifier: value.identifier,
1325
+ password: Redacted.value(value.password)
1326
+ })
1327
+ )
1328
+ )
1329
+ ).pipe(Effect.mapError(toBskyError("Bluesky login failed", "login")));
1330
+ });
1331
+
1332
+ const paginate = <A>(
1333
+ initialCursor: string | undefined,
1334
+ fetch: (cursor: string | undefined) => Effect.Effect<
1335
+ readonly [Chunk.Chunk<A>, Option.Option<string>],
1336
+ BskyError
1337
+ >
1338
+ ) => Stream.paginateChunkEffect(initialCursor, fetch);
1339
+
1340
+ const getTimeline = (opts?: TimelineOptions) =>
1341
+ paginate(opts?.cursor, (cursor) =>
1342
+ Effect.gen(function* () {
1343
+ yield* ensureAuth(true);
1344
+ const params = withCursor(
1345
+ { limit: opts?.limit ?? 50 },
1346
+ cursor
1347
+ );
1348
+ const response = yield* withRetry(
1349
+ withRateLimit(
1350
+ Effect.tryPromise<AppBskyFeedGetTimeline.Response>(() =>
1351
+ agent.app.bsky.feed.getTimeline(params)
1352
+ )
1353
+ )
1354
+ ).pipe(Effect.mapError(toBskyError("Failed to fetch timeline", "getTimeline")));
1355
+ const posts = yield* toRawPostsFromFeed(response.data.feed);
1356
+ const nextCursor = response.data.cursor;
1357
+ const tagged = posts.map((p) => new RawPost({ ...p, _pageCursor: nextCursor }));
1358
+ const hasNext =
1359
+ tagged.length > 0 &&
1360
+ typeof nextCursor === "string" &&
1361
+ nextCursor !== cursor;
1362
+ return [
1363
+ Chunk.fromIterable(tagged),
1364
+ hasNext ? Option.some(nextCursor) : Option.none()
1365
+ ] as const;
1366
+ })
1367
+ );
1368
+
1369
+ const getFeed = (uri: string, opts?: FeedOptions) =>
1370
+ paginate(opts?.cursor, (cursor) =>
1371
+ Effect.gen(function* () {
1372
+ yield* ensureAuth(false);
1373
+ const params = withCursor(
1374
+ { feed: uri, limit: opts?.limit ?? 50 },
1375
+ cursor
1376
+ );
1377
+ const response = yield* withRetry(
1378
+ withRateLimit(
1379
+ Effect.tryPromise<AppBskyFeedGetFeed.Response>(() =>
1380
+ agent.app.bsky.feed.getFeed(params)
1381
+ )
1382
+ )
1383
+ ).pipe(Effect.mapError(toBskyError("Failed to fetch feed", "getFeed")));
1384
+ const posts = yield* toRawPostsFromFeed(response.data.feed);
1385
+ const nextCursor = response.data.cursor;
1386
+ const tagged = posts.map((p) => new RawPost({ ...p, _pageCursor: nextCursor }));
1387
+ const hasNext =
1388
+ tagged.length > 0 &&
1389
+ typeof nextCursor === "string" &&
1390
+ nextCursor !== cursor;
1391
+ return [
1392
+ Chunk.fromIterable(tagged),
1393
+ hasNext ? Option.some(nextCursor) : Option.none()
1394
+ ] as const;
1395
+ })
1396
+ );
1397
+
1398
+ const getListFeed = (uri: string, opts?: FeedOptions) =>
1399
+ paginate(opts?.cursor, (cursor) =>
1400
+ Effect.gen(function* () {
1401
+ yield* ensureAuth(false);
1402
+ const params = withCursor(
1403
+ { list: uri, limit: opts?.limit ?? 50 },
1404
+ cursor
1405
+ );
1406
+ const response = yield* withRetry(
1407
+ withRateLimit(
1408
+ Effect.tryPromise<AppBskyFeedGetListFeed.Response>(() =>
1409
+ agent.app.bsky.feed.getListFeed(params)
1410
+ )
1411
+ )
1412
+ ).pipe(Effect.mapError(toBskyError("Failed to fetch list feed", "getListFeed")));
1413
+ const posts = yield* toRawPostsFromFeed(response.data.feed);
1414
+ const nextCursor = response.data.cursor;
1415
+ const tagged = posts.map((p) => new RawPost({ ...p, _pageCursor: nextCursor }));
1416
+ const hasNext =
1417
+ tagged.length > 0 &&
1418
+ typeof nextCursor === "string" &&
1419
+ nextCursor !== cursor;
1420
+ return [
1421
+ Chunk.fromIterable(tagged),
1422
+ hasNext ? Option.some(nextCursor) : Option.none()
1423
+ ] as const;
1424
+ })
1425
+ );
1426
+
1427
+ const getAuthorFeed = (actor: string, opts?: AuthorFeedOptions) =>
1428
+ paginate(opts?.cursor, (cursor) =>
1429
+ Effect.gen(function* () {
1430
+ yield* ensureAuth(false);
1431
+ const includePins = opts?.includePins;
1432
+ const params = withCursor(
1433
+ {
1434
+ actor,
1435
+ limit: opts?.limit ?? 50,
1436
+ ...(opts?.filter ? { filter: opts.filter } : {}),
1437
+ ...(includePins !== undefined ? { includePins } : {})
1438
+ },
1439
+ cursor
1440
+ );
1441
+ const response = yield* withRetry(
1442
+ withRateLimit(
1443
+ Effect.tryPromise<AppBskyFeedGetAuthorFeed.Response>(() =>
1444
+ agent.app.bsky.feed.getAuthorFeed(params)
1445
+ )
1446
+ )
1447
+ ).pipe(Effect.mapError(toBskyError("Failed to fetch author feed", "getAuthorFeed")));
1448
+ const posts = yield* toRawPostsFromFeed(response.data.feed);
1449
+ const nextCursor = response.data.cursor;
1450
+ const tagged = posts.map((p) => new RawPost({ ...p, _pageCursor: nextCursor }));
1451
+ const hasNext =
1452
+ tagged.length > 0 &&
1453
+ typeof nextCursor === "string" &&
1454
+ nextCursor !== cursor;
1455
+ return [
1456
+ Chunk.fromIterable(tagged),
1457
+ hasNext ? Option.some(nextCursor) : Option.none()
1458
+ ] as const;
1459
+ })
1460
+ );
1461
+
1462
+ const getPost = (uri: string) =>
1463
+ Effect.gen(function* () {
1464
+ yield* ensureAuth(false);
1465
+ const response = yield* withRetry(
1466
+ withRateLimit(
1467
+ Effect.tryPromise<AppBskyFeedGetPosts.Response>(() =>
1468
+ agent.app.bsky.feed.getPosts({ uris: [uri] })
1469
+ )
1470
+ )
1471
+ ).pipe(Effect.mapError(toBskyError("Failed to fetch post", "getPosts")));
1472
+ const postView = response.data.posts[0];
1473
+ if (!postView) {
1474
+ return yield* BskyError.make({
1475
+ message: "Post not found",
1476
+ cause: uri
1477
+ });
1478
+ }
1479
+ return yield* toRawPost(postView);
1480
+ });
1481
+
1482
+ const getPostThread = (uri: string, opts?: ThreadOptions) =>
1483
+ Effect.gen(function* () {
1484
+ yield* ensureAuth(false);
1485
+ const params = {
1486
+ uri,
1487
+ ...(opts?.depth !== undefined ? { depth: opts.depth } : {}),
1488
+ ...(opts?.parentHeight !== undefined
1489
+ ? { parentHeight: opts.parentHeight }
1490
+ : {})
1491
+ };
1492
+ const response = yield* withRetry(
1493
+ withRateLimit(
1494
+ Effect.tryPromise<AppBskyFeedGetPostThread.Response>(() =>
1495
+ agent.app.bsky.feed.getPostThread(params)
1496
+ )
1497
+ )
1498
+ ).pipe(Effect.mapError(toBskyError("Failed to fetch post thread", "getPostThread")));
1499
+
1500
+ if (!AppBskyFeedDefs.isThreadViewPost(response.data.thread)) {
1501
+ return [] as ReadonlyArray<RawPost>;
1502
+ }
1503
+
1504
+ return yield* toRawPostsFromThread(response.data.thread);
1505
+ });
1506
+
1507
+ const getFollowers = (actor: string, opts?: GraphOptions) =>
1508
+ Effect.gen(function* () {
1509
+ yield* ensureAuth(false);
1510
+ const params = withCursor(
1511
+ { actor, limit: opts?.limit ?? 50 },
1512
+ opts?.cursor
1513
+ );
1514
+ const response = yield* withRetry(
1515
+ withRateLimit(
1516
+ Effect.tryPromise<AppBskyGraphGetFollowers.Response>(() =>
1517
+ agent.app.bsky.graph.getFollowers(params)
1518
+ )
1519
+ )
1520
+ ).pipe(Effect.mapError(toBskyError("Failed to fetch followers", "getFollowers")));
1521
+ const subject = yield* decodeProfileView(response.data.subject);
1522
+ const followers = yield* Effect.forEach(
1523
+ response.data.followers,
1524
+ decodeProfileView,
1525
+ { concurrency: "unbounded" }
1526
+ );
1527
+ const cursor = response.data.cursor;
1528
+ return cursor ? { subject, followers, cursor } : { subject, followers };
1529
+ });
1530
+
1531
+ const getFollows = (actor: string, opts?: GraphOptions) =>
1532
+ Effect.gen(function* () {
1533
+ yield* ensureAuth(false);
1534
+ const params = withCursor(
1535
+ { actor, limit: opts?.limit ?? 50 },
1536
+ opts?.cursor
1537
+ );
1538
+ const response = yield* withRetry(
1539
+ withRateLimit(
1540
+ Effect.tryPromise<AppBskyGraphGetFollows.Response>(() =>
1541
+ agent.app.bsky.graph.getFollows(params)
1542
+ )
1543
+ )
1544
+ ).pipe(Effect.mapError(toBskyError("Failed to fetch follows", "getFollows")));
1545
+ const subject = yield* decodeProfileView(response.data.subject);
1546
+ const follows = yield* Effect.forEach(
1547
+ response.data.follows,
1548
+ decodeProfileView,
1549
+ { concurrency: "unbounded" }
1550
+ );
1551
+ const cursor = response.data.cursor;
1552
+ return cursor ? { subject, follows, cursor } : { subject, follows };
1553
+ });
1554
+
1555
+ const getKnownFollowers = (actor: string, opts?: GraphOptions) =>
1556
+ Effect.gen(function* () {
1557
+ yield* ensureAuth(true);
1558
+ const params = withCursor(
1559
+ { actor, limit: opts?.limit ?? 50 },
1560
+ opts?.cursor
1561
+ );
1562
+ const response = yield* withRetry(
1563
+ withRateLimit(
1564
+ Effect.tryPromise<AppBskyGraphGetKnownFollowers.Response>(() =>
1565
+ agent.app.bsky.graph.getKnownFollowers(params)
1566
+ )
1567
+ )
1568
+ ).pipe(Effect.mapError(toBskyError("Failed to fetch known followers", "getKnownFollowers")));
1569
+ const subject = yield* decodeProfileView(response.data.subject);
1570
+ const followers = yield* Effect.forEach(
1571
+ response.data.followers,
1572
+ decodeProfileView,
1573
+ { concurrency: "unbounded" }
1574
+ );
1575
+ const cursor = response.data.cursor;
1576
+ return cursor ? { subject, followers, cursor } : { subject, followers };
1577
+ });
1578
+
1579
+ const getRelationships = (actor: string, others: ReadonlyArray<string>) =>
1580
+ Effect.gen(function* () {
1581
+ if (others.length === 0) {
1582
+ return { actor, relationships: [] };
1583
+ }
1584
+ yield* ensureAuth(false);
1585
+ const response = yield* withRetry(
1586
+ withRateLimit(
1587
+ Effect.tryPromise<AppBskyGraphGetRelationships.Response>(() =>
1588
+ agent.app.bsky.graph.getRelationships({ actor, others: [...others] })
1589
+ )
1590
+ )
1591
+ ).pipe(Effect.mapError(toBskyError("Failed to fetch relationships", "getRelationships")));
1592
+ const relationships = yield* Effect.forEach(
1593
+ response.data.relationships,
1594
+ decodeRelationshipView,
1595
+ { concurrency: "unbounded" }
1596
+ );
1597
+ const actorDid =
1598
+ typeof response.data.actor === "string" ? response.data.actor : actor;
1599
+ return { actor: actorDid, relationships };
1600
+ });
1601
+
1602
+ const getList = (uri: string, opts?: GraphOptions) =>
1603
+ Effect.gen(function* () {
1604
+ yield* ensureAuth(false);
1605
+ const params = withCursor(
1606
+ { list: uri, limit: opts?.limit ?? 50 },
1607
+ opts?.cursor
1608
+ );
1609
+ const response = yield* withRetry(
1610
+ withRateLimit(
1611
+ Effect.tryPromise<AppBskyGraphGetList.Response>(() =>
1612
+ agent.app.bsky.graph.getList(params)
1613
+ )
1614
+ )
1615
+ ).pipe(Effect.mapError(toBskyError("Failed to fetch list", "getList")));
1616
+ const list = yield* decodeListView(response.data.list);
1617
+ const items = yield* Effect.forEach(
1618
+ response.data.items,
1619
+ decodeListItemView,
1620
+ { concurrency: "unbounded" }
1621
+ );
1622
+ const cursor = response.data.cursor;
1623
+ return cursor ? { list, items, cursor } : { list, items };
1624
+ });
1625
+
1626
+ const getLists = (actor: string, opts?: GraphListsOptions) =>
1627
+ Effect.gen(function* () {
1628
+ yield* ensureAuth(false);
1629
+ const params = withCursor(
1630
+ {
1631
+ actor,
1632
+ limit: opts?.limit ?? 50,
1633
+ ...(opts?.purposes && opts.purposes.length > 0
1634
+ ? { purposes: [...opts.purposes] }
1635
+ : {})
1636
+ },
1637
+ opts?.cursor
1638
+ );
1639
+ const response = yield* withRetry(
1640
+ withRateLimit(
1641
+ Effect.tryPromise<AppBskyGraphGetLists.Response>(() =>
1642
+ agent.app.bsky.graph.getLists(params)
1643
+ )
1644
+ )
1645
+ ).pipe(Effect.mapError(toBskyError("Failed to fetch lists", "getLists")));
1646
+ const lists = yield* Effect.forEach(
1647
+ response.data.lists,
1648
+ decodeListView,
1649
+ { concurrency: "unbounded" }
1650
+ );
1651
+ const cursor = response.data.cursor;
1652
+ return cursor ? { lists, cursor } : { lists };
1653
+ });
1654
+
1655
+ const getBlocks = (opts?: GraphOptions) =>
1656
+ Effect.gen(function* () {
1657
+ yield* ensureAuth(true);
1658
+ const params = withCursor(
1659
+ { limit: opts?.limit ?? 50 },
1660
+ opts?.cursor
1661
+ );
1662
+ const response = yield* withRetry(
1663
+ withRateLimit(
1664
+ Effect.tryPromise<AppBskyGraphGetBlocks.Response>(() =>
1665
+ agent.app.bsky.graph.getBlocks(params)
1666
+ )
1667
+ )
1668
+ ).pipe(Effect.mapError(toBskyError("Failed to fetch blocks", "getBlocks")));
1669
+ const blocks = yield* Effect.forEach(
1670
+ response.data.blocks,
1671
+ decodeProfileView,
1672
+ { concurrency: "unbounded" }
1673
+ );
1674
+ const cursor = response.data.cursor;
1675
+ return cursor ? { blocks, cursor } : { blocks };
1676
+ });
1677
+
1678
+ const getMutes = (opts?: GraphOptions) =>
1679
+ Effect.gen(function* () {
1680
+ yield* ensureAuth(true);
1681
+ const params = withCursor(
1682
+ { limit: opts?.limit ?? 50 },
1683
+ opts?.cursor
1684
+ );
1685
+ const response = yield* withRetry(
1686
+ withRateLimit(
1687
+ Effect.tryPromise<AppBskyGraphGetMutes.Response>(() =>
1688
+ agent.app.bsky.graph.getMutes(params)
1689
+ )
1690
+ )
1691
+ ).pipe(Effect.mapError(toBskyError("Failed to fetch mutes", "getMutes")));
1692
+ const mutes = yield* Effect.forEach(
1693
+ response.data.mutes,
1694
+ decodeProfileView,
1695
+ { concurrency: "unbounded" }
1696
+ );
1697
+ const cursor = response.data.cursor;
1698
+ return cursor ? { mutes, cursor } : { mutes };
1699
+ });
1700
+
1701
+ const getFeedGenerator = (uri: string) =>
1702
+ Effect.gen(function* () {
1703
+ yield* ensureAuth(false);
1704
+ const response = yield* withRetry(
1705
+ withRateLimit(
1706
+ Effect.tryPromise<AppBskyFeedGetFeedGenerator.Response>(() =>
1707
+ agent.app.bsky.feed.getFeedGenerator({ feed: uri })
1708
+ )
1709
+ )
1710
+ ).pipe(Effect.mapError(toBskyError("Failed to fetch feed generator", "getFeedGenerator")));
1711
+ const view = yield* decodeFeedGeneratorView(response.data.view);
1712
+ return {
1713
+ view,
1714
+ isOnline: response.data.isOnline,
1715
+ isValid: response.data.isValid
1716
+ };
1717
+ });
1718
+
1719
+ const getFeedGenerators = (uris: ReadonlyArray<string>) =>
1720
+ Effect.gen(function* () {
1721
+ yield* ensureAuth(false);
1722
+ if (uris.length === 0) {
1723
+ return { feeds: [] as ReadonlyArray<FeedGeneratorView> };
1724
+ }
1725
+ const response = yield* withRetry(
1726
+ withRateLimit(
1727
+ Effect.tryPromise<AppBskyFeedGetFeedGenerators.Response>(() =>
1728
+ agent.app.bsky.feed.getFeedGenerators({ feeds: [...uris] })
1729
+ )
1730
+ )
1731
+ ).pipe(Effect.mapError(toBskyError("Failed to fetch feed generators", "getFeedGenerators")));
1732
+ const feeds = yield* Effect.forEach(
1733
+ response.data.feeds,
1734
+ decodeFeedGeneratorView,
1735
+ { concurrency: "unbounded" }
1736
+ );
1737
+ return { feeds };
1738
+ });
1739
+
1740
+ const getActorFeeds = (actor: string, opts?: ActorFeedsOptions) =>
1741
+ Effect.gen(function* () {
1742
+ yield* ensureAuth(false);
1743
+ const params = withCursor(
1744
+ { actor, limit: opts?.limit ?? 50 },
1745
+ opts?.cursor
1746
+ );
1747
+ const response = yield* withRetry(
1748
+ withRateLimit(
1749
+ Effect.tryPromise<AppBskyFeedGetActorFeeds.Response>(() =>
1750
+ agent.app.bsky.feed.getActorFeeds(params)
1751
+ )
1752
+ )
1753
+ ).pipe(Effect.mapError(toBskyError("Failed to fetch actor feeds", "getActorFeeds")));
1754
+ const feeds = yield* Effect.forEach(
1755
+ response.data.feeds,
1756
+ decodeFeedGeneratorView,
1757
+ { concurrency: "unbounded" }
1758
+ );
1759
+ const cursor = response.data.cursor;
1760
+ return cursor ? { feeds, cursor } : { feeds };
1761
+ });
1762
+
1763
+ const getLikes = (uri: string, opts?: EngagementOptions) =>
1764
+ Effect.gen(function* () {
1765
+ yield* ensureAuth(false);
1766
+ const params = withCursor(
1767
+ { uri, limit: opts?.limit ?? 50, ...(opts?.cid ? { cid: opts.cid } : {}) },
1768
+ opts?.cursor
1769
+ );
1770
+ const response = yield* withRetry(
1771
+ withRateLimit(
1772
+ Effect.tryPromise<AppBskyFeedGetLikes.Response>(() =>
1773
+ agent.app.bsky.feed.getLikes(params)
1774
+ )
1775
+ )
1776
+ ).pipe(Effect.mapError(toBskyError("Failed to fetch likes", "getLikes")));
1777
+ const likes = yield* Effect.forEach(
1778
+ response.data.likes,
1779
+ decodePostLike,
1780
+ { concurrency: "unbounded" }
1781
+ );
1782
+ const cursor = response.data.cursor;
1783
+ return {
1784
+ uri: response.data.uri,
1785
+ ...(typeof response.data.cid === "string" ? { cid: response.data.cid } : {}),
1786
+ likes,
1787
+ ...(cursor ? { cursor } : {})
1788
+ };
1789
+ });
1790
+
1791
+ const getRepostedBy = (uri: string, opts?: EngagementOptions) =>
1792
+ Effect.gen(function* () {
1793
+ yield* ensureAuth(false);
1794
+ const params = withCursor(
1795
+ { uri, limit: opts?.limit ?? 50, ...(opts?.cid ? { cid: opts.cid } : {}) },
1796
+ opts?.cursor
1797
+ );
1798
+ const response = yield* withRetry(
1799
+ withRateLimit(
1800
+ Effect.tryPromise<AppBskyFeedGetRepostedBy.Response>(() =>
1801
+ agent.app.bsky.feed.getRepostedBy(params)
1802
+ )
1803
+ )
1804
+ ).pipe(Effect.mapError(toBskyError("Failed to fetch reposts", "getRepostedBy")));
1805
+ const repostedBy = yield* Effect.forEach(
1806
+ response.data.repostedBy,
1807
+ decodeProfileView,
1808
+ { concurrency: "unbounded" }
1809
+ );
1810
+ const cursor = response.data.cursor;
1811
+ return {
1812
+ uri: response.data.uri,
1813
+ ...(typeof response.data.cid === "string" ? { cid: response.data.cid } : {}),
1814
+ repostedBy,
1815
+ ...(cursor ? { cursor } : {})
1816
+ };
1817
+ });
1818
+
1819
+ const getQuotes = (uri: string, opts?: EngagementOptions) =>
1820
+ Effect.gen(function* () {
1821
+ yield* ensureAuth(false);
1822
+ const params = withCursor(
1823
+ { uri, limit: opts?.limit ?? 50, ...(opts?.cid ? { cid: opts.cid } : {}) },
1824
+ opts?.cursor
1825
+ );
1826
+ const response = yield* withRetry(
1827
+ withRateLimit(
1828
+ Effect.tryPromise<AppBskyFeedGetQuotes.Response>(() =>
1829
+ agent.app.bsky.feed.getQuotes(params)
1830
+ )
1831
+ )
1832
+ ).pipe(Effect.mapError(toBskyError("Failed to fetch quotes", "getQuotes")));
1833
+ const posts = yield* Effect.forEach(
1834
+ response.data.posts,
1835
+ (post) => toRawPost(post),
1836
+ { concurrency: "unbounded" }
1837
+ );
1838
+ const cursor = response.data.cursor;
1839
+ return {
1840
+ uri: response.data.uri,
1841
+ ...(typeof response.data.cid === "string" ? { cid: response.data.cid } : {}),
1842
+ posts,
1843
+ ...(cursor ? { cursor } : {})
1844
+ };
1845
+ });
1846
+
1847
+ const resolveHandle = (handle: string) =>
1848
+ Effect.gen(function* () {
1849
+ yield* ensureAuth(false);
1850
+ const response = yield* withRetry(
1851
+ withRateLimit(
1852
+ Effect.tryPromise(() => agent.resolveHandle({ handle }))
1853
+ )
1854
+ ).pipe(Effect.mapError(toBskyError("Failed to resolve handle", "resolveHandle")));
1855
+ return yield* decodeDid(response.data.did, "Invalid DID from resolveHandle");
1856
+ });
1857
+
1858
+ const resolveIdentity = (identifier: string) =>
1859
+ Effect.gen(function* () {
1860
+ yield* ensureAuth(false);
1861
+ const response = yield* withRetry(
1862
+ withRateLimit(
1863
+ Effect.tryPromise<ComAtprotoIdentityResolveIdentity.Response>(() =>
1864
+ agent.com.atproto.identity.resolveIdentity({ identifier })
1865
+ )
1866
+ )
1867
+ ).pipe(
1868
+ Effect.mapError(toBskyError("Failed to resolve identity", "resolveIdentity"))
1869
+ );
1870
+ return yield* decodeIdentityInfo(response.data);
1871
+ });
1872
+
1873
+ const getProfiles = (actors: ReadonlyArray<string>) =>
1874
+ Effect.gen(function* () {
1875
+ const uniqueActors = Array.from(new Set(actors));
1876
+ if (uniqueActors.length === 0) {
1877
+ return [];
1878
+ }
1879
+ yield* ensureAuth(false);
1880
+ const batches = chunkArray(uniqueActors, 25);
1881
+ const results = yield* Effect.forEach(
1882
+ batches,
1883
+ (batch) =>
1884
+ withRetry(
1885
+ withRateLimit(
1886
+ Effect.tryPromise<AppBskyActorGetProfiles.Response>(() =>
1887
+ agent.app.bsky.actor.getProfiles({ actors: batch })
1888
+ )
1889
+ )
1890
+ ).pipe(
1891
+ Effect.mapError(toBskyError("Failed to fetch profiles", "getProfiles")),
1892
+ Effect.flatMap((response) =>
1893
+ Effect.forEach(response.data.profiles, decodeProfileBasic, {
1894
+ concurrency: "unbounded"
1895
+ })
1896
+ )
1897
+ ),
1898
+ { concurrency: "unbounded" }
1899
+ );
1900
+ return results.flat();
1901
+ });
1902
+
1903
+ const searchActors = (query: string, opts?: ActorSearchOptions) =>
1904
+ Effect.gen(function* () {
1905
+ yield* ensureAuth(false);
1906
+ if (opts?.typeahead) {
1907
+ const response = yield* withRetry(
1908
+ withRateLimit(
1909
+ Effect.tryPromise<AppBskyActorSearchActorsTypeahead.Response>(() =>
1910
+ agent.app.bsky.actor.searchActorsTypeahead({
1911
+ q: query,
1912
+ limit: opts.limit ?? 10
1913
+ })
1914
+ )
1915
+ )
1916
+ ).pipe(Effect.mapError(toBskyError("Failed to search actors", "searchActorsTypeahead")));
1917
+ const actors = yield* Effect.forEach(
1918
+ response.data.actors,
1919
+ decodeProfileView,
1920
+ { concurrency: "unbounded" }
1921
+ );
1922
+ return { actors };
1923
+ }
1924
+
1925
+ const params = withCursor(
1926
+ { q: query, limit: opts?.limit ?? 25 },
1927
+ opts?.cursor
1928
+ );
1929
+ const response = yield* withRetry(
1930
+ withRateLimit(
1931
+ Effect.tryPromise<AppBskyActorSearchActors.Response>(() =>
1932
+ agent.app.bsky.actor.searchActors(params)
1933
+ )
1934
+ )
1935
+ ).pipe(Effect.mapError(toBskyError("Failed to search actors", "searchActors")));
1936
+ const actors = yield* Effect.forEach(
1937
+ response.data.actors,
1938
+ decodeProfileView,
1939
+ { concurrency: "unbounded" }
1940
+ );
1941
+ const cursor = response.data.cursor;
1942
+ return cursor ? { actors, cursor } : { actors };
1943
+ });
1944
+
1945
+ const searchFeedGenerators = (query: string, opts?: FeedSearchOptions) =>
1946
+ Effect.gen(function* () {
1947
+ yield* ensureAuth(false);
1948
+ const params = withCursor(
1949
+ { query, limit: opts?.limit ?? 25 },
1950
+ opts?.cursor
1951
+ );
1952
+ const response = yield* withRetry(
1953
+ withRateLimit(
1954
+ Effect.tryPromise<AppBskyUnspeccedGetPopularFeedGenerators.Response>(() =>
1955
+ agent.app.bsky.unspecced.getPopularFeedGenerators(params)
1956
+ )
1957
+ )
1958
+ ).pipe(Effect.mapError(toBskyError("Failed to search feed generators", "searchFeedGenerators")));
1959
+ const feeds = yield* Effect.forEach(
1960
+ response.data.feeds,
1961
+ decodeFeedGeneratorView,
1962
+ { concurrency: "unbounded" }
1963
+ );
1964
+ const cursor = response.data.cursor;
1965
+ return cursor ? { feeds, cursor } : { feeds };
1966
+ });
1967
+
1968
+ const searchPosts = (query: string, opts?: NetworkSearchOptions) =>
1969
+ Effect.gen(function* () {
1970
+ yield* ensureAuth(false);
1971
+ const params = {
1972
+ q: query,
1973
+ ...(opts?.sort ? { sort: opts.sort } : {}),
1974
+ ...(opts?.since ? { since: opts.since } : {}),
1975
+ ...(opts?.until ? { until: opts.until } : {}),
1976
+ ...(opts?.mentions ? { mentions: opts.mentions } : {}),
1977
+ ...(opts?.author ? { author: opts.author } : {}),
1978
+ ...(opts?.lang ? { lang: opts.lang } : {}),
1979
+ ...(opts?.domain ? { domain: opts.domain } : {}),
1980
+ ...(opts?.url ? { url: opts.url } : {}),
1981
+ ...(opts?.tags && opts.tags.length > 0 ? { tag: [...opts.tags] } : {}),
1982
+ limit: opts?.limit ?? 25,
1983
+ ...(opts?.cursor ? { cursor: opts.cursor } : {})
1984
+ };
1985
+ const response = yield* withRetry(
1986
+ withRateLimit(
1987
+ Effect.tryPromise<AppBskyFeedSearchPosts.Response>(() =>
1988
+ agent.app.bsky.feed.searchPosts(params)
1989
+ )
1990
+ )
1991
+ ).pipe(Effect.mapError(toBskyError("Failed to search posts", "searchPosts")));
1992
+ const posts = yield* Effect.forEach(
1993
+ response.data.posts,
1994
+ (post) => toRawPost(post),
1995
+ { concurrency: "unbounded" }
1996
+ );
1997
+ const cursor = response.data.cursor;
1998
+ const hitsTotal = response.data.hitsTotal;
1999
+ return {
2000
+ posts,
2001
+ ...(typeof cursor === "string" ? { cursor } : {}),
2002
+ ...(typeof hitsTotal === "number" ? { hitsTotal } : {})
2003
+ };
2004
+ });
2005
+
2006
+ const getNotifications = (opts?: NotificationsOptions) =>
2007
+ paginate(opts?.cursor, (cursor) =>
2008
+ Effect.gen(function* () {
2009
+ yield* ensureAuth(true);
2010
+ const params = withCursor({ limit: opts?.limit ?? 50 }, cursor);
2011
+ const response = yield* withRetry(
2012
+ withRateLimit(
2013
+ Effect.tryPromise<AppBskyNotificationListNotifications.Response>(() =>
2014
+ agent.app.bsky.notification.listNotifications(params)
2015
+ )
2016
+ )
2017
+ ).pipe(Effect.mapError(toBskyError("Failed to fetch notifications", "listNotifications")));
2018
+
2019
+ const posts = yield* Effect.forEach(
2020
+ response.data.notifications,
2021
+ (notification) =>
2022
+ Effect.gen(function* () {
2023
+ if (!isPostRecord(notification.record)) {
2024
+ return Option.none<RawPost>();
2025
+ }
2026
+ const raw = {
2027
+ uri: notification.uri,
2028
+ cid: notification.cid,
2029
+ author: notification.author.handle,
2030
+ authorDid: notification.author.did,
2031
+ record: notification.record,
2032
+ indexedAt: notification.indexedAt,
2033
+ labels: notification.labels
2034
+ };
2035
+ const parsed = yield* Schema.decodeUnknown(RawPost)(raw).pipe(
2036
+ Effect.mapError(toBskyError("Invalid notification payload"))
2037
+ );
2038
+ return Option.some(parsed);
2039
+ })
2040
+ );
2041
+
2042
+ const filtered = posts.flatMap((item) =>
2043
+ Option.isSome(item) ? [item.value] : []
2044
+ );
2045
+ const nextCursor = response.data.cursor;
2046
+ const tagged = filtered.map((p) => new RawPost({ ...p, _pageCursor: nextCursor }));
2047
+ const hasNext =
2048
+ tagged.length > 0 &&
2049
+ typeof nextCursor === "string" &&
2050
+ nextCursor !== cursor;
2051
+ return [
2052
+ Chunk.fromIterable(tagged),
2053
+ hasNext ? Option.some(nextCursor) : Option.none()
2054
+ ] as const;
2055
+ })
2056
+ );
2057
+
2058
+ const getTrendingTopics = Effect.gen(function* () {
2059
+ yield* ensureAuth(false);
2060
+ const response = yield* withRetry(
2061
+ withRateLimit(
2062
+ Effect.tryPromise<AppBskyUnspeccedGetTrendingTopics.Response>(() =>
2063
+ agent.app.bsky.unspecced.getTrendingTopics({
2064
+ limit: 25,
2065
+ ...(agent.did ? { viewer: agent.did } : {})
2066
+ })
2067
+ )
2068
+ )
2069
+ ).pipe(Effect.mapError(toBskyError("Failed to fetch trending topics", "getTrendingTopics")));
2070
+
2071
+ const topics = [
2072
+ ...response.data.topics,
2073
+ ...response.data.suggested
2074
+ ]
2075
+ .map((topic) => topic.topic.trim().toLowerCase().replace(/^#/, ""))
2076
+ .filter((topic) => topic.length > 0);
2077
+
2078
+ return Array.from(new Set(topics));
2079
+ });
2080
+
2081
+ return BskyClient.of({
2082
+ getTimeline,
2083
+ getNotifications,
2084
+ getFeed,
2085
+ getListFeed,
2086
+ getAuthorFeed,
2087
+ getPost,
2088
+ getPostThread,
2089
+ getFollowers,
2090
+ getFollows,
2091
+ getKnownFollowers,
2092
+ getRelationships,
2093
+ getList,
2094
+ getLists,
2095
+ getBlocks,
2096
+ getMutes,
2097
+ getFeedGenerator,
2098
+ getFeedGenerators,
2099
+ getActorFeeds,
2100
+ getLikes,
2101
+ getRepostedBy,
2102
+ getQuotes,
2103
+ resolveHandle,
2104
+ resolveIdentity,
2105
+ getProfiles,
2106
+ searchActors,
2107
+ searchFeedGenerators,
2108
+ searchPosts,
2109
+ getTrendingTopics: () => getTrendingTopics
2110
+ });
2111
+ })
2112
+ );
2113
+ }