A TypeScript toolkit for consuming the Bluesky network in real-time.
1import {
2 JetstreamCommitEventSchema,
3 JetstreamIdentityEventSchema,
4 JetstreamAccountEventSchema,
5 PostRecordSchema,
6} from "./types.js";
7import type {
8 JetstreamCommitEvent,
9 JetstreamIdentityEvent,
10 JetstreamAccountEvent,
11 PostData,
12 PostUpdateData,
13 PostDeleteData,
14 KeywordPost,
15 UserPost,
16 UserRegistry,
17 IdentityPayload,
18 AccountPayload,
19 PostView,
20 PostViewInfo,
21} from "./types.js";
22import { toBskyUrl } from "./formatting.js";
23
24// --- Facet extraction ---
25
26export const extractFeatures = (
27 facets: any[] | undefined,
28 type: string,
29 field: string
30): readonly string[] =>
31 facets
32 ?.flatMap((f: any) => f.features ?? [])
33 ?.filter((f: any) => f.$type === type)
34 ?.map((f: any) => f[field]) ?? [];
35
36export const extractLinks = (facets: any[] | undefined): readonly string[] =>
37 extractFeatures(facets, "app.bsky.richtext.facet#link", "uri");
38
39export const extractMentions = (facets: any[] | undefined): readonly string[] =>
40 extractFeatures(facets, "app.bsky.richtext.facet#mention", "did");
41
42// --- Event kind guards (zod-backed) ---
43
44export const isCommitEvent = (event: unknown): event is JetstreamCommitEvent =>
45 JetstreamCommitEventSchema.safeParse(event).success;
46
47export const isIdentityEvent = (
48 event: unknown
49): event is JetstreamIdentityEvent =>
50 JetstreamIdentityEventSchema.safeParse(event).success;
51
52export const isAccountEvent = (
53 event: unknown
54): event is JetstreamAccountEvent =>
55 JetstreamAccountEventSchema.safeParse(event).success;
56
57// --- Collection-generic operation guards ---
58
59export const isCreate = (event: unknown, collection: string): boolean => {
60 const result = JetstreamCommitEventSchema.safeParse(event);
61 return (
62 result.success &&
63 result.data.commit.operation === "create" &&
64 result.data.commit.collection === collection
65 );
66};
67
68export const isUpdate = (event: unknown, collection: string): boolean => {
69 const result = JetstreamCommitEventSchema.safeParse(event);
70 return (
71 result.success &&
72 result.data.commit.operation === "update" &&
73 result.data.commit.collection === collection
74 );
75};
76
77export const isDelete = (event: unknown, collection: string): boolean => {
78 const result = JetstreamCommitEventSchema.safeParse(event);
79 return (
80 result.success &&
81 result.data.commit.operation === "delete" &&
82 result.data.commit.collection === collection
83 );
84};
85
86// --- Post-specific guards ---
87
88export const isPostCreate = (event: unknown): boolean =>
89 isCreate(event, "app.bsky.feed.post");
90
91export const isPostUpdate = (event: unknown): boolean =>
92 isUpdate(event, "app.bsky.feed.post");
93
94export const isPostDelete = (event: unknown): boolean =>
95 isDelete(event, "app.bsky.feed.post");
96
97// --- Shared post-field extraction ---
98
99const extractPostFields = (
100 did: string,
101 rkey: string,
102 record: Record<string, any>
103): Omit<PostData, "did" | "rkey" | "uri"> | null => {
104 const parsed = PostRecordSchema.safeParse(record);
105 if (!parsed.success) return null;
106
107 return {
108 text: parsed.data.text,
109 langs: parsed.data.langs ?? [],
110 links: [...extractLinks(parsed.data.facets)],
111 mentionCount: extractMentions(parsed.data.facets).length,
112 embedType: parsed.data.embed?.$type ?? null,
113 createdAt: parsed.data.createdAt,
114 };
115};
116
117// --- Post parsing ---
118
119export const parsePost = (event: unknown): PostData | null => {
120 const result = JetstreamCommitEventSchema.safeParse(event);
121 if (!result.success) return null;
122 const { did, commit } = result.data;
123 if (commit.operation !== "create" || commit.collection !== "app.bsky.feed.post")
124 return null;
125
126 const fields = extractPostFields(did, commit.rkey, commit.record);
127 if (!fields) return null;
128
129 return {
130 did,
131 rkey: commit.rkey,
132 uri: buildAtUri(did, "app.bsky.feed.post", commit.rkey),
133 ...fields,
134 };
135};
136
137export const parsePostUpdate = (event: unknown): PostUpdateData | null => {
138 const result = JetstreamCommitEventSchema.safeParse(event);
139 if (!result.success) return null;
140 const { did, commit } = result.data;
141 if (commit.operation !== "update" || commit.collection !== "app.bsky.feed.post")
142 return null;
143
144 const fields = extractPostFields(did, commit.rkey, commit.record);
145 if (!fields) return null;
146
147 return {
148 did,
149 rkey: commit.rkey,
150 uri: buildAtUri(did, "app.bsky.feed.post", commit.rkey),
151 cid: commit.cid,
152 ...fields,
153 };
154};
155
156export const parsePostDelete = (event: unknown): PostDeleteData | null => {
157 const result = JetstreamCommitEventSchema.safeParse(event);
158 if (!result.success) return null;
159 const { did, commit } = result.data;
160 if (commit.operation !== "delete" || commit.collection !== "app.bsky.feed.post")
161 return null;
162
163 return {
164 did,
165 collection: commit.collection,
166 rkey: commit.rkey,
167 uri: buildAtUri(did, commit.collection, commit.rkey),
168 };
169};
170
171// --- Identity & Account parsers ---
172
173export const parseIdentityEvent = (event: unknown): IdentityPayload | null => {
174 const result = JetstreamIdentityEventSchema.safeParse(event);
175 if (!result.success) return null;
176 return result.data.identity;
177};
178
179export const parseAccountEvent = (event: unknown): AccountPayload | null => {
180 const result = JetstreamAccountEventSchema.safeParse(event);
181 if (!result.success) return null;
182 return result.data.account;
183};
184
185// --- Keyword matching ---
186
187const escapeRegex = (s: string): string =>
188 s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
189
190export const findMatchingKeywords = (
191 text: string,
192 keywords: readonly string[]
193): string[] =>
194 keywords.filter((kw) =>
195 new RegExp(`\\b${escapeRegex(kw)}\\b`, "i").test(text)
196 );
197
198export const parseKeywordPost = (
199 event: unknown,
200 keywords: readonly string[]
201): KeywordPost | null => {
202 const post = parsePost(event);
203 if (!post) return null;
204
205 const matched = findMatchingKeywords(post.text, keywords);
206 if (matched.length === 0) return null;
207
208 return { ...post, matchedKeywords: matched };
209};
210
211// --- User matching ---
212
213export const parseUserPost = (
214 event: unknown,
215 registry: UserRegistry
216): UserPost | null => {
217 const result = JetstreamCommitEventSchema.safeParse(event);
218 if (!result.success) return null;
219 const { did, commit } = result.data;
220 if (commit.operation !== "create" || commit.collection !== "app.bsky.feed.post")
221 return null;
222
223 const user = registry.get(did);
224 if (!user) return null;
225
226 const fields = extractPostFields(did, commit.rkey, commit.record);
227 if (!fields) return null;
228
229 return {
230 did,
231 rkey: commit.rkey,
232 uri: buildAtUri(did, "app.bsky.feed.post", commit.rkey),
233 ...fields,
234 displayName: user.displayName || "???",
235 handle: user.handle || "unknown",
236 };
237};
238
239// --- User merging ---
240
241export const mergeUsers = (
242 existing: UserRegistry,
243 actors: readonly any[]
244): { merged: UserRegistry; newCount: number } => {
245 const merged = new Map(existing);
246 let newCount = 0;
247
248 for (const actor of actors) {
249 if (!merged.has(actor.did)) {
250 merged.set(actor.did, {
251 displayName: actor.displayName ?? "",
252 handle: actor.handle,
253 });
254 newCount++;
255 }
256 }
257
258 return { merged, newCount };
259};
260
261// --- AT URI helpers ---
262
263export const buildAtUri = (
264 did: string,
265 collection: string,
266 rkey: string
267): string => `at://${did}/${collection}/${rkey}`;
268
269export const parseAtUri = (
270 uri: string
271): { did: string; collection: string; rkey: string } | null => {
272 const match = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/);
273 if (!match) return null;
274 return { did: match[1], collection: match[2], rkey: match[3] };
275};
276
277// --- bsky.app URL parsing ---
278
279export const parseBskyUrl = (
280 url: string
281): { id: string; rkey: string } | null => {
282 const match = url.match(/bsky\.app\/profile\/([^/]+)\/post\/([^/?#]+)/);
283 return match ? { id: match[1], rkey: match[2] } : null;
284};
285
286// --- PostView info extraction ---
287
288export const extractPostViewInfo = (post: PostView): PostViewInfo => {
289 const text: string = (post.record as any)?.text ?? "(no text)";
290 const displayName = post.author?.displayName || post.author?.handle || "???";
291 const handle = post.author?.handle || "unknown";
292 const did = post.author?.did ?? "";
293 const parsed = parseAtUri(post.uri ?? "");
294 const rkey = parsed?.rkey ?? "";
295 const bskyUrl = parsed ? toBskyUrl(parsed.did, parsed.rkey) : post.uri ?? "";
296 return { text, displayName, handle, did, rkey, bskyUrl, uri: post.uri };
297};