A TypeScript toolkit for consuming the Bluesky network in real-time.
at main 297 lines 8.4 kB view raw
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};